Merge: org structure (divisions/products) + custom model base URL
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { OrgChartPage } from '@/pages/OrgChartPage'
|
||||
import { PerformancePage } from '@/pages/PerformancePage'
|
||||
import { ReviewsPage } from '@/pages/ReviewsPage'
|
||||
import { SeatsPage } from '@/pages/SeatsPage'
|
||||
import { StructurePage } from '@/pages/StructurePage'
|
||||
import { useAuth } from '@/store/auth'
|
||||
|
||||
export default function App() {
|
||||
@@ -25,6 +26,7 @@ export default function App() {
|
||||
<Route path="/cartable" element={token ? <CartablePage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/members" element={token ? <MembersPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/org" element={token ? <OrgChartPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/structure" element={token ? <StructurePage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/performance" element={token ? <PerformancePage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
|
||||
import { Link, useLocation } from 'react-router'
|
||||
import {
|
||||
Bot,
|
||||
Boxes,
|
||||
ChartColumn,
|
||||
Gauge,
|
||||
Inbox,
|
||||
@@ -42,6 +43,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" />
|
||||
<NavItem icon={Bot} label="AI seats" to="/seats" />
|
||||
<NavItem icon={Network} label="Org chart" to="/org" />
|
||||
<NavItem icon={Boxes} label="Structure" to="/structure" />
|
||||
<NavItem icon={Users} label="Members" to="/members" />
|
||||
<NavItem icon={Gauge} label="Performance" to="/performance" />
|
||||
<NavItem icon={ChartColumn} label="Analytics" to="/analytics" />
|
||||
|
||||
@@ -7,18 +7,34 @@ import { api } from '@/lib/api'
|
||||
import { useAuth } from '@/store/auth'
|
||||
import type { SeatRow } from '@/lib/useDirectory'
|
||||
|
||||
interface Division {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: string
|
||||
divisionId: string | null
|
||||
name: string
|
||||
kind: string
|
||||
}
|
||||
|
||||
interface Team {
|
||||
id: string
|
||||
organizationId: string
|
||||
name: string
|
||||
productId: string | null
|
||||
}
|
||||
|
||||
const TEAM_WIDTH = 280
|
||||
const SEAT_HEIGHT = 64
|
||||
const LAYER_HEIGHT = 100
|
||||
|
||||
/** The live org chart: org → teams → seats, painted with the human/open/AI triad. */
|
||||
/** The live org chart: org → divisions → products → teams → seats, painted with the triad. */
|
||||
export function OrgChartPage() {
|
||||
const organizationId = useAuth((s) => s.organizationId)
|
||||
const [divisions, setDivisions] = useState<Division[]>([])
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [teams, setTeams] = useState<Team[]>([])
|
||||
const [seatsByTeam, setSeatsByTeam] = useState<Record<string, SeatRow[]>>({})
|
||||
|
||||
@@ -26,7 +42,13 @@ export function OrgChartPage() {
|
||||
if (!organizationId) return
|
||||
void (async () => {
|
||||
try {
|
||||
const teamList = await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`)
|
||||
const [divisionList, productList, teamList] = await Promise.all([
|
||||
api.get<Division[]>(`/api/orgboard/divisions?organizationId=${organizationId}`),
|
||||
api.get<Product[]>(`/api/orgboard/products?organizationId=${organizationId}`),
|
||||
api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`),
|
||||
])
|
||||
setDivisions(divisionList)
|
||||
setProducts(productList)
|
||||
setTeams(teamList)
|
||||
const entries = await Promise.all(
|
||||
teamList.map(async (team) => {
|
||||
@@ -44,7 +66,10 @@ export function OrgChartPage() {
|
||||
})()
|
||||
}, [organizationId])
|
||||
|
||||
const { nodes, edges } = useMemo(() => buildGraph(teams, seatsByTeam), [teams, seatsByTeam])
|
||||
const { nodes, edges } = useMemo(
|
||||
() => buildGraph(divisions, products, teams, seatsByTeam),
|
||||
[divisions, products, teams, seatsByTeam],
|
||||
)
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
@@ -67,14 +92,26 @@ export function OrgChartPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function buildGraph(teams: Team[], seatsByTeam: Record<string, SeatRow[]>): { nodes: Node[]; edges: Edge[] } {
|
||||
function buildGraph(
|
||||
divisions: Division[],
|
||||
products: Product[],
|
||||
teams: Team[],
|
||||
seatsByTeam: Record<string, SeatRow[]>,
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const nodes: Node[] = []
|
||||
const edges: Edge[] = []
|
||||
if (teams.length === 0) {
|
||||
if (teams.length === 0 && products.length === 0 && divisions.length === 0) {
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
const totalWidth = teams.length * TEAM_WIDTH
|
||||
const hasDivisions = divisions.length > 0
|
||||
const hasProducts = products.length > 0
|
||||
const divisionY = LAYER_HEIGHT
|
||||
const productY = divisionY + (hasDivisions ? LAYER_HEIGHT : 0)
|
||||
const teamY = productY + (hasProducts ? LAYER_HEIGHT : 0)
|
||||
const seatY = teamY + LAYER_HEIGHT
|
||||
|
||||
const totalWidth = Math.max(teams.length, products.length, divisions.length, 1) * TEAM_WIDTH
|
||||
|
||||
nodes.push({
|
||||
id: 'org',
|
||||
@@ -90,22 +127,24 @@ function buildGraph(teams: Team[], seatsByTeam: Record<string, SeatRow[]>): { no
|
||||
},
|
||||
})
|
||||
|
||||
// Teams anchor the columns; seats stack underneath each team.
|
||||
const teamX = new Map<string, number>()
|
||||
teams.forEach((team, teamIndex) => {
|
||||
const x = teamIndex * TEAM_WIDTH
|
||||
teamX.set(team.id, x)
|
||||
nodes.push({
|
||||
id: team.id,
|
||||
position: { x, y: 110 },
|
||||
position: { x, y: teamY },
|
||||
data: { label: team.name },
|
||||
style: { borderRadius: 10, fontWeight: 600, width: 200 },
|
||||
})
|
||||
edges.push({ id: `org-${team.id}`, source: 'org', target: team.id })
|
||||
|
||||
const seats = seatsByTeam[team.id] ?? []
|
||||
seats.forEach((seat, seatIndex) => {
|
||||
const color = seat.state === 'Ai' ? '#4f46e5' : seat.state === 'Human' ? '#475569' : '#d97706'
|
||||
nodes.push({
|
||||
id: seat.id,
|
||||
position: { x: x + 10, y: 210 + seatIndex * SEAT_HEIGHT },
|
||||
position: { x: x + 10, y: seatY + seatIndex * SEAT_HEIGHT },
|
||||
data: { label: `${seat.roleName} · ${seat.state === 'Ai' ? 'AI' : seat.state}` },
|
||||
style: {
|
||||
background: color,
|
||||
@@ -120,5 +159,45 @@ function buildGraph(teams: Team[], seatsByTeam: Record<string, SeatRow[]>): { no
|
||||
})
|
||||
})
|
||||
|
||||
// Products sit above their teams (centered); parentless ones get slots after the team row.
|
||||
const productX = new Map<string, number>()
|
||||
let overflowX = totalWidth
|
||||
products.forEach((product) => {
|
||||
const childXs = teams.filter((t) => t.productId === product.id).map((t) => teamX.get(t.id) ?? 0)
|
||||
const x = childXs.length > 0 ? childXs.reduce((a, b) => a + b, 0) / childXs.length : (overflowX += TEAM_WIDTH) - TEAM_WIDTH
|
||||
productX.set(product.id, x)
|
||||
nodes.push({
|
||||
id: product.id,
|
||||
position: { x: x + 5, y: productY },
|
||||
data: { label: `${product.name} · ${product.kind.toLowerCase()}` },
|
||||
style: { borderRadius: 10, width: 190, fontSize: 13, border: '1.5px solid #4f46e5' },
|
||||
})
|
||||
})
|
||||
|
||||
// Divisions sit above their products.
|
||||
const divisionX = new Map<string, number>()
|
||||
divisions.forEach((division) => {
|
||||
const childXs = products.filter((p) => p.divisionId === division.id).map((p) => productX.get(p.id) ?? 0)
|
||||
const x = childXs.length > 0 ? childXs.reduce((a, b) => a + b, 0) / childXs.length : (overflowX += TEAM_WIDTH) - TEAM_WIDTH
|
||||
divisionX.set(division.id, x)
|
||||
nodes.push({
|
||||
id: division.id,
|
||||
position: { x: x + 10, y: divisionY },
|
||||
data: { label: division.name },
|
||||
style: { borderRadius: 10, width: 180, fontWeight: 600, fontSize: 13, background: '#eef2ff' },
|
||||
})
|
||||
edges.push({ id: `org-${division.id}`, source: 'org', target: division.id })
|
||||
})
|
||||
|
||||
// Wire each layer to its nearest existing parent.
|
||||
products.forEach((product) => {
|
||||
const source = product.divisionId && divisionX.has(product.divisionId) ? product.divisionId : 'org'
|
||||
edges.push({ id: `${source}-${product.id}`, source, target: product.id })
|
||||
})
|
||||
teams.forEach((team) => {
|
||||
const source = team.productId && productX.has(team.productId) ? team.productId : 'org'
|
||||
edges.push({ id: `${source}-${team.id}`, source, target: team.id })
|
||||
})
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ interface ApiConfig {
|
||||
name: string
|
||||
provider: string
|
||||
model: string
|
||||
endpoint: string | null
|
||||
}
|
||||
|
||||
interface Seat {
|
||||
@@ -71,7 +72,7 @@ export function SeatsPage() {
|
||||
const [seats, setSeats] = useState<Seat[]>([])
|
||||
const [skills, setSkills] = useState<Skill[]>([])
|
||||
|
||||
const [cfg, setCfg] = useState({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '' })
|
||||
const [cfg, setCfg] = useState({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '', endpoint: '' })
|
||||
const [newSeat, setNewSeat] = useState('')
|
||||
const [selectedSeat, setSelectedSeat] = useState<string | null>(null)
|
||||
const [agent, setAgent] = useState({
|
||||
@@ -115,8 +116,8 @@ export function SeatsPage() {
|
||||
|
||||
const createConfig = () =>
|
||||
run(async () => {
|
||||
await api.post('/api/integrations/api-configs', { organizationId, ...cfg })
|
||||
setCfg({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '' })
|
||||
await api.post('/api/integrations/api-configs', { organizationId, ...cfg, endpoint: cfg.endpoint.trim() || null })
|
||||
setCfg({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '', endpoint: '' })
|
||||
await loadConfigs()
|
||||
toast.success('API config saved (key encrypted).')
|
||||
})
|
||||
@@ -207,7 +208,7 @@ export function SeatsPage() {
|
||||
<SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{['stub', 'openai', 'anthropic', 'vertex', 'ollama'].map((p) => (
|
||||
{['stub', 'openai', 'ollama', 'vllm', 'custom'].map((p) => (
|
||||
<SelectItem key={p} value={p}>{p}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
@@ -220,13 +221,24 @@ export function SeatsPage() {
|
||||
<Field label="API key">
|
||||
<Input type="password" value={cfg.apiKey} onChange={(e) => setCfg({ ...cfg, apiKey: e.target.value })} className="w-44" placeholder="sk-…" />
|
||||
</Field>
|
||||
<Field label="Base URL (OpenAI-compatible; optional)">
|
||||
<Input
|
||||
value={cfg.endpoint}
|
||||
onChange={(e) => setCfg({ ...cfg, endpoint: e.target.value })}
|
||||
className="w-72"
|
||||
placeholder="https://my-gateway.example.com"
|
||||
/>
|
||||
</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>
|
||||
<span className="text-muted-foreground">
|
||||
{c.provider} · {c.model}
|
||||
{c.endpoint ? ` · ${c.endpoint}` : ''}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => testConfig(c.id)}>Test</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Boxes, Plus } 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 { api } from '@/lib/api'
|
||||
import { useAuth } from '@/store/auth'
|
||||
|
||||
interface Division {
|
||||
id: string
|
||||
organizationId: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: string
|
||||
organizationId: string
|
||||
divisionId: string | null
|
||||
name: string
|
||||
kind: string
|
||||
}
|
||||
|
||||
interface Team {
|
||||
id: string
|
||||
organizationId: string
|
||||
name: string
|
||||
productId: string | null
|
||||
}
|
||||
|
||||
const NONE = 'none'
|
||||
|
||||
/** Define the org structure: divisions → products/services → teams. */
|
||||
export function StructurePage() {
|
||||
const organizationId = useAuth((s) => s.organizationId)
|
||||
const [divisions, setDivisions] = useState<Division[]>([])
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [teams, setTeams] = useState<Team[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const [divisionName, setDivisionName] = useState('')
|
||||
const [product, setProduct] = useState({ name: '', kind: 'Product', divisionId: NONE })
|
||||
const [team, setTeam] = useState({ name: '', productId: NONE })
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!organizationId) return
|
||||
try {
|
||||
const [d, p, t] = await Promise.all([
|
||||
api.get<Division[]>(`/api/orgboard/divisions?organizationId=${organizationId}`),
|
||||
api.get<Product[]>(`/api/orgboard/products?organizationId=${organizationId}`),
|
||||
api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`),
|
||||
])
|
||||
setDivisions(d)
|
||||
setProducts(p)
|
||||
setTeams(t)
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
}, [organizationId])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const run = async (action: () => Promise<void>) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await action()
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addDivision = () =>
|
||||
run(async () => {
|
||||
await api.post('/api/orgboard/divisions', { organizationId, name: divisionName })
|
||||
setDivisionName('')
|
||||
toast.success('Division created.')
|
||||
})
|
||||
|
||||
const addProduct = () =>
|
||||
run(async () => {
|
||||
await api.post('/api/orgboard/products', {
|
||||
organizationId,
|
||||
name: product.name,
|
||||
kind: product.kind,
|
||||
divisionId: product.divisionId === NONE ? null : product.divisionId,
|
||||
})
|
||||
setProduct({ name: '', kind: 'Product', divisionId: NONE })
|
||||
toast.success('Product created.')
|
||||
})
|
||||
|
||||
const addTeam = () =>
|
||||
run(async () => {
|
||||
await api.post('/api/orgboard/teams', {
|
||||
organizationId,
|
||||
name: team.name,
|
||||
productId: team.productId === NONE ? null : team.productId,
|
||||
})
|
||||
setTeam({ name: '', productId: NONE })
|
||||
toast.success('Team created.')
|
||||
})
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="mx-auto max-w-4xl p-6">
|
||||
<header className="mb-6">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<Boxes className="size-6" /> Structure
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The object spine: organization → divisions → products/services → teams.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Divisions</CardTitle>
|
||||
<CardDescription>Technical, Finance, HR, Sales — the top-level slices.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex items-end gap-3">
|
||||
<Field label="Name">
|
||||
<Input value={divisionName} onChange={(e) => setDivisionName(e.target.value)} className="w-56" placeholder="Technical" />
|
||||
</Field>
|
||||
<Button disabled={busy || !divisionName.trim()} onClick={addDivision}>
|
||||
<Plus data-icon="inline-start" />Add division
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{divisions.map((d) => (
|
||||
<Badge key={d.id} variant="secondary">{d.name}</Badge>
|
||||
))}
|
||||
{divisions.length === 0 && <p className="text-sm text-muted-foreground">No divisions yet — optional, but they unlock the full org chart.</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Products & services</CardTitle>
|
||||
<CardDescription>Engineering divisions ship products; other divisions run services.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<Field label="Name">
|
||||
<Input value={product.name} onChange={(e) => setProduct({ ...product, name: e.target.value })} className="w-48" placeholder="IPNOPS" />
|
||||
</Field>
|
||||
<Field label="Kind">
|
||||
<Select value={product.kind} onValueChange={(v) => setProduct({ ...product, kind: v })}>
|
||||
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="Product">Product</SelectItem>
|
||||
<SelectItem value="Service">Service</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Division">
|
||||
<Select value={product.divisionId} onValueChange={(v) => setProduct({ ...product, divisionId: v })}>
|
||||
<SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value={NONE}>— none —</SelectItem>
|
||||
{divisions.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button disabled={busy || !product.name.trim()} onClick={addProduct}>
|
||||
<Plus data-icon="inline-start" />Add product
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{products.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
|
||||
<span className="font-medium">{p.name}</span>
|
||||
<Badge variant="outline">{p.kind}</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{p.divisionId ? divisions.find((d) => d.id === p.divisionId)?.name ?? '' : 'no division'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{products.length === 0 && <p className="text-sm text-muted-foreground">No products yet.</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Teams</CardTitle>
|
||||
<CardDescription>Teams run delivery. Attach them to a product to complete the spine.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<Field label="Name">
|
||||
<Input value={team.name} onChange={(e) => setTeam({ ...team, name: e.target.value })} className="w-48" placeholder="Core team" />
|
||||
</Field>
|
||||
<Field label="Product / service">
|
||||
<Select value={team.productId} onValueChange={(v) => setTeam({ ...team, productId: v })}>
|
||||
<SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value={NONE}>— none —</SelectItem>
|
||||
{products.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button disabled={busy || !team.name.trim()} onClick={addTeam}>
|
||||
<Plus data-icon="inline-start" />Add team
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{teams.map((t) => (
|
||||
<div key={t.id} className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
|
||||
<span className="font-medium">{t.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t.productId ? products.find((p) => p.id === t.productId)?.name ?? '' : 'directly under the org'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{teams.length === 0 && <p className="text-sm text-muted-foreground">No teams yet.</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using TeamUp.SharedKernel.Domain;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Domain;
|
||||
|
||||
/// <summary>An organizational division (Technical, Finance, HR, …). Products/services live under it.</summary>
|
||||
internal sealed class Division : Entity
|
||||
{
|
||||
public Guid OrganizationId { get; private set; }
|
||||
public string Name { get; private set; } = null!;
|
||||
public DateTimeOffset CreatedAtUtc { get; private set; }
|
||||
|
||||
private Division()
|
||||
{
|
||||
}
|
||||
|
||||
public Division(Guid organizationId, string name, DateTimeOffset createdAtUtc)
|
||||
{
|
||||
OrganizationId = organizationId;
|
||||
Name = name;
|
||||
CreatedAtUtc = createdAtUtc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using TeamUp.SharedKernel.Domain;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Domain;
|
||||
|
||||
/// <summary>Engineering divisions organize around products; other divisions around services.</summary>
|
||||
internal enum ProductKind
|
||||
{
|
||||
Product,
|
||||
Service,
|
||||
}
|
||||
|
||||
/// <summary>A product or service — the same entity with a kind tag. Teams live under it.</summary>
|
||||
internal sealed class Product : Entity
|
||||
{
|
||||
public Guid OrganizationId { get; private set; }
|
||||
public Guid? DivisionId { get; private set; }
|
||||
public string Name { get; private set; } = null!;
|
||||
public ProductKind Kind { get; private set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; private set; }
|
||||
|
||||
private Product()
|
||||
{
|
||||
}
|
||||
|
||||
public Product(Guid organizationId, Guid? divisionId, string name, ProductKind kind, DateTimeOffset createdAtUtc)
|
||||
{
|
||||
OrganizationId = organizationId;
|
||||
DivisionId = divisionId;
|
||||
Name = name;
|
||||
Kind = kind;
|
||||
CreatedAtUtc = createdAtUtc;
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,14 @@ using TeamUp.SharedKernel.Domain;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Domain;
|
||||
|
||||
/// <summary>A team within an organization. Team-level memberships are granted at its id (Team scope).</summary>
|
||||
/// <summary>
|
||||
/// A team within an organization, optionally under a product/service. Team-level memberships are
|
||||
/// granted at its id (Team scope). ProductId is nullable so pre-structure teams keep working.
|
||||
/// </summary>
|
||||
internal sealed class Team : Entity
|
||||
{
|
||||
public Guid OrganizationId { get; private set; }
|
||||
public Guid? ProductId { get; private set; }
|
||||
public string Name { get; private set; } = null!;
|
||||
public DateTimeOffset CreatedAtUtc { get; private set; }
|
||||
|
||||
@@ -13,10 +17,11 @@ internal sealed class Team : Entity
|
||||
{
|
||||
}
|
||||
|
||||
public Team(Guid organizationId, string name, DateTimeOffset createdAtUtc)
|
||||
public Team(Guid organizationId, string name, DateTimeOffset createdAtUtc, Guid? productId = null)
|
||||
{
|
||||
OrganizationId = organizationId;
|
||||
Name = name;
|
||||
CreatedAtUtc = createdAtUtc;
|
||||
ProductId = productId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,17 @@ internal sealed record CreateOrganizationRequest(Guid OrganizationId, string Nam
|
||||
|
||||
internal sealed record OrganizationResponse(Guid Id, string Name);
|
||||
|
||||
internal sealed record CreateTeamRequest(Guid OrganizationId, string Name);
|
||||
internal sealed record CreateTeamRequest(Guid OrganizationId, string Name, Guid? ProductId = null);
|
||||
|
||||
internal sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name);
|
||||
internal sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name, Guid? ProductId = null);
|
||||
|
||||
internal sealed record CreateDivisionRequest(Guid OrganizationId, string Name);
|
||||
|
||||
internal sealed record DivisionResponse(Guid Id, Guid OrganizationId, string Name);
|
||||
|
||||
internal sealed record CreateProductRequest(Guid OrganizationId, string Name, ProductKind Kind, Guid? DivisionId = null);
|
||||
|
||||
internal sealed record ProductResponse(Guid Id, Guid OrganizationId, Guid? DivisionId, string Name, string Kind);
|
||||
|
||||
internal sealed record CreateTaskRequest(Guid TeamId, string Title, string? Description, WorkItemType Type);
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ internal static class OrgBoardEndpoints
|
||||
|
||||
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("orgboard")));
|
||||
group.MapPost("/organizations", CreateOrganization).RequireAuthorization();
|
||||
group.MapPost("/divisions", CreateDivision).RequireAuthorization();
|
||||
group.MapGet("/divisions", ListDivisions).RequireAuthorization();
|
||||
group.MapPost("/products", CreateProduct).RequireAuthorization();
|
||||
group.MapGet("/products", ListProducts).RequireAuthorization();
|
||||
group.MapPost("/teams", CreateTeam).RequireAuthorization();
|
||||
group.MapGet("/teams", ListTeams).RequireAuthorization();
|
||||
group.MapPost("/tasks", CreateTask).RequireAuthorization();
|
||||
@@ -87,11 +91,97 @@ internal static class OrgBoardEndpoints
|
||||
return Results.BadRequest("Organization does not exist; create it first.");
|
||||
}
|
||||
|
||||
var team = new Team(request.OrganizationId, request.Name.Trim(), clock.GetUtcNow());
|
||||
if (request.ProductId is { } productId
|
||||
&& !await db.Products.AnyAsync(p => p.Id == productId && p.OrganizationId == request.OrganizationId, ct))
|
||||
{
|
||||
return Results.BadRequest("Product not found in this organization.");
|
||||
}
|
||||
|
||||
var team = new Team(request.OrganizationId, request.Name.Trim(), clock.GetUtcNow(), request.ProductId);
|
||||
db.Teams.Add(team);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await audit.WriteAsync(new AuditEvent("team.created", "Team", team.Id, user.MemberId, team.Name), ct);
|
||||
return Results.Ok(new TeamResponse(team.Id, team.OrganizationId, team.Name));
|
||||
return Results.Ok(new TeamResponse(team.Id, team.OrganizationId, team.Name, team.ProductId));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateDivision(
|
||||
CreateDivisionRequest request, ICurrentUser user, IPermissionService permissions,
|
||||
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest("Name is required.");
|
||||
}
|
||||
|
||||
var division = new Division(request.OrganizationId, request.Name.Trim(), clock.GetUtcNow());
|
||||
db.Divisions.Add(division);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await audit.WriteAsync(new AuditEvent("division.created", "Division", division.Id, user.MemberId, division.Name), ct);
|
||||
return Results.Ok(new DivisionResponse(division.Id, division.OrganizationId, division.Name));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListDivisions(
|
||||
Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(organizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var divisions = await db.Divisions
|
||||
.Where(d => d.OrganizationId == organizationId)
|
||||
.OrderBy(d => d.CreatedAtUtc)
|
||||
.Select(d => new DivisionResponse(d.Id, d.OrganizationId, d.Name))
|
||||
.ToListAsync(ct);
|
||||
return Results.Ok(divisions);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateProduct(
|
||||
CreateProductRequest request, ICurrentUser user, IPermissionService permissions,
|
||||
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest("Name is required.");
|
||||
}
|
||||
|
||||
if (request.DivisionId is { } divisionId
|
||||
&& !await db.Divisions.AnyAsync(d => d.Id == divisionId && d.OrganizationId == request.OrganizationId, ct))
|
||||
{
|
||||
return Results.BadRequest("Division not found in this organization.");
|
||||
}
|
||||
|
||||
var product = new Product(request.OrganizationId, request.DivisionId, request.Name.Trim(), request.Kind, clock.GetUtcNow());
|
||||
db.Products.Add(product);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await audit.WriteAsync(new AuditEvent("product.created", "Product", product.Id, user.MemberId, product.Name), ct);
|
||||
return Results.Ok(new ProductResponse(product.Id, product.OrganizationId, product.DivisionId, product.Name, product.Kind.ToString()));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListProducts(
|
||||
Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(organizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var products = await db.Products
|
||||
.Where(p => p.OrganizationId == organizationId)
|
||||
.OrderBy(p => p.CreatedAtUtc)
|
||||
.Select(p => new ProductResponse(p.Id, p.OrganizationId, p.DivisionId, p.Name, p.Kind.ToString()))
|
||||
.ToListAsync(ct);
|
||||
return Results.Ok(products);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListTeams(
|
||||
@@ -105,7 +195,7 @@ internal static class OrgBoardEndpoints
|
||||
var teams = await db.Teams
|
||||
.Where(t => t.OrganizationId == organizationId)
|
||||
.OrderBy(t => t.CreatedAtUtc)
|
||||
.Select(t => new TeamResponse(t.Id, t.OrganizationId, t.Name))
|
||||
.Select(t => new TeamResponse(t.Id, t.OrganizationId, t.Name, t.ProductId))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Results.Ok(teams);
|
||||
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
// <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("20260610142338_AddDivisionsAndProducts")]
|
||||
partial class AddDivisionsAndProducts
|
||||
{
|
||||
/// <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.Division", 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("divisions", "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.Product", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DivisionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DivisionId");
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.ToTable("products", "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.Property<Guid?>("ProductId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.HasIndex("ProductId");
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItemTransition", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ActorMemberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FromStatus")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<DateTimeOffset>("OccurredAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("TeamId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ToStatus")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<Guid>("WorkItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TeamId");
|
||||
|
||||
b.HasIndex("WorkItemId");
|
||||
|
||||
b.ToTable("work_item_transitions", "orgboard");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDivisionsAndProducts : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "ProductId",
|
||||
schema: "orgboard",
|
||||
table: "teams",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "divisions",
|
||||
schema: "orgboard",
|
||||
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(200)", maxLength: 200, nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_divisions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "products",
|
||||
schema: "orgboard",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
DivisionId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Kind = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_products", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_teams_ProductId",
|
||||
schema: "orgboard",
|
||||
table: "teams",
|
||||
column: "ProductId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_divisions_OrganizationId",
|
||||
schema: "orgboard",
|
||||
table: "divisions",
|
||||
column: "OrganizationId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_products_DivisionId",
|
||||
schema: "orgboard",
|
||||
table: "products",
|
||||
column: "DivisionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_products_OrganizationId",
|
||||
schema: "orgboard",
|
||||
table: "products",
|
||||
column: "OrganizationId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "divisions",
|
||||
schema: "orgboard");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "products",
|
||||
schema: "orgboard");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_teams_ProductId",
|
||||
schema: "orgboard",
|
||||
table: "teams");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProductId",
|
||||
schema: "orgboard",
|
||||
table: "teams");
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -75,6 +75,30 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||
b.ToTable("agents", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Division", 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("divisions", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -94,6 +118,40 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||
b.ToTable("organizations", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Product", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DivisionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DivisionId");
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.ToTable("products", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -146,10 +204,15 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ProductId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.HasIndex("ProductId");
|
||||
|
||||
b.ToTable("teams", "orgboard");
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
|
||||
: DbContext(options), IModuleDbContext
|
||||
{
|
||||
public DbSet<Organization> Organizations => Set<Organization>();
|
||||
public DbSet<Division> Divisions => Set<Division>();
|
||||
public DbSet<Product> Products => Set<Product>();
|
||||
public DbSet<Team> Teams => Set<Team>();
|
||||
public DbSet<Seat> Seats => Set<Seat>();
|
||||
public DbSet<Agent> Agents => Set<Agent>();
|
||||
@@ -25,12 +27,31 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
|
||||
organization.Property(o => o.Name).HasMaxLength(200).IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Division>(division =>
|
||||
{
|
||||
division.ToTable("divisions");
|
||||
division.HasKey(d => d.Id);
|
||||
division.Property(d => d.Name).HasMaxLength(200).IsRequired();
|
||||
division.HasIndex(d => d.OrganizationId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Product>(product =>
|
||||
{
|
||||
product.ToTable("products");
|
||||
product.HasKey(p => p.Id);
|
||||
product.Property(p => p.Name).HasMaxLength(200).IsRequired();
|
||||
product.Property(p => p.Kind).HasConversion<string>().HasMaxLength(16);
|
||||
product.HasIndex(p => p.OrganizationId);
|
||||
product.HasIndex(p => p.DivisionId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Team>(team =>
|
||||
{
|
||||
team.ToTable("teams");
|
||||
team.HasKey(t => t.Id);
|
||||
team.Property(t => t.Name).HasMaxLength(200).IsRequired();
|
||||
team.HasIndex(t => t.OrganizationId);
|
||||
team.HasIndex(t => t.ProductId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Seat>(seat =>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace TeamUp.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// The org structure spine: divisions → products/services → teams. Owner-only writes, org-scoped
|
||||
/// validation, and teams optionally attached to a product (nullable for pre-structure teams).
|
||||
/// </summary>
|
||||
public sealed class OrgStructureTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
||||
{
|
||||
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 DivisionResponse(Guid Id, Guid OrganizationId, string Name);
|
||||
|
||||
private sealed record ProductResponse(Guid Id, Guid OrganizationId, Guid? DivisionId, string Name, string Kind);
|
||||
|
||||
private sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name, Guid? ProductId);
|
||||
|
||||
[Fact]
|
||||
public async Task Divisions_products_and_teams_form_the_spine()
|
||||
{
|
||||
await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
|
||||
using var anon = factory.CreateClient();
|
||||
|
||||
var owner = await PostOk<BootstrapResponse>(anon, "/api/identity/bootstrap", new
|
||||
{
|
||||
organizationName = "AliaSaaS",
|
||||
ownerEmail = "owner@alia.test",
|
||||
ownerDisplayName = "Owner",
|
||||
ownerPassword = "Passw0rd!",
|
||||
});
|
||||
using var client = Authed(factory, owner.Token);
|
||||
await client.PostAsJsonAsync("/api/orgboard/organizations", new { organizationId = owner.OrganizationId, name = "AliaSaaS" });
|
||||
|
||||
// Division → product under it; a service with no division; both listable.
|
||||
var technical = await PostOk<DivisionResponse>(client, "/api/orgboard/divisions",
|
||||
new { organizationId = owner.OrganizationId, name = "Technical" });
|
||||
|
||||
var ipnops = await PostOk<ProductResponse>(client, "/api/orgboard/products",
|
||||
new { organizationId = owner.OrganizationId, name = "IPNOPS", kind = "Product", divisionId = technical.Id });
|
||||
Assert.Equal(technical.Id, ipnops.DivisionId);
|
||||
|
||||
var payroll = await PostOk<ProductResponse>(client, "/api/orgboard/products",
|
||||
new { organizationId = owner.OrganizationId, name = "Payroll", kind = "Service" });
|
||||
Assert.Null(payroll.DivisionId);
|
||||
Assert.Equal("Service", payroll.Kind);
|
||||
|
||||
var divisions = await client.GetFromJsonAsync<List<DivisionResponse>>(
|
||||
$"/api/orgboard/divisions?organizationId={owner.OrganizationId}");
|
||||
Assert.Contains(divisions!, d => d.Name == "Technical");
|
||||
|
||||
var products = await client.GetFromJsonAsync<List<ProductResponse>>(
|
||||
$"/api/orgboard/products?organizationId={owner.OrganizationId}");
|
||||
Assert.Equal(2, products!.Count);
|
||||
|
||||
// A team under the product; a team without one still works (backward compatible).
|
||||
var coreTeam = await PostOk<TeamResponse>(client, "/api/orgboard/teams",
|
||||
new { organizationId = owner.OrganizationId, name = "Core", productId = ipnops.Id });
|
||||
Assert.Equal(ipnops.Id, coreTeam.ProductId);
|
||||
|
||||
var looseTeam = await PostOk<TeamResponse>(client, "/api/orgboard/teams",
|
||||
new { organizationId = owner.OrganizationId, name = "Loose" });
|
||||
Assert.Null(looseTeam.ProductId);
|
||||
|
||||
var teams = await client.GetFromJsonAsync<List<TeamResponse>>(
|
||||
$"/api/orgboard/teams?organizationId={owner.OrganizationId}");
|
||||
Assert.Contains(teams!, t => t.Id == coreTeam.Id && t.ProductId == ipnops.Id);
|
||||
|
||||
// Validation: a product can't attach to a foreign/unknown division; nor a team to an unknown product.
|
||||
var badProduct = await client.PostAsJsonAsync("/api/orgboard/products",
|
||||
new { organizationId = owner.OrganizationId, name = "X", kind = "Product", divisionId = Guid.NewGuid() });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, badProduct.StatusCode);
|
||||
|
||||
var badTeam = await client.PostAsJsonAsync("/api/orgboard/teams",
|
||||
new { organizationId = owner.OrganizationId, name = "X", productId = Guid.NewGuid() });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, badTeam.StatusCode);
|
||||
|
||||
// A plain Member cannot create structure (owner capability).
|
||||
var invite = await PostOk<InviteResponse>(client, "/api/identity/invitations", new
|
||||
{
|
||||
email = "dev@alia.test",
|
||||
scopeType = "Organization",
|
||||
scopeId = owner.OrganizationId,
|
||||
role = "Member",
|
||||
organizationId = owner.OrganizationId,
|
||||
});
|
||||
var member = await PostOk<AuthResponse>(anon, "/api/identity/invitations/accept",
|
||||
new { token = invite.Token, displayName = "Dev", password = "Passw0rd!" });
|
||||
using var memberClient = Authed(factory, member.Token);
|
||||
|
||||
var forbidden = await memberClient.PostAsJsonAsync("/api/orgboard/divisions",
|
||||
new { organizationId = owner.OrganizationId, name = "Nope" });
|
||||
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
|
||||
}
|
||||
|
||||
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