Org structure: divisions → products/services → teams + custom model base URL

The object spine becomes definable (data model was designed-for from day one):
- Division and Product entities (Product carries kind: Product|Service, optional DivisionId);
  Team gains nullable ProductId — pre-structure teams keep working. AddDivisionsAndProducts
  migration; org-scoped validation; owner-only writes (audited); list endpoints.
- /structure page: define divisions, products/services (with division), teams (under a
  product). Org chart now renders the full spine — org → divisions → products → teams →
  seats — with parentless layers linking up to the org.
- BYOK custom URL: the SeatsPage model-connection form gains a Base URL field (provider
  list: stub/openai/ollama/vllm/custom). Backend already supported it end to end —
  ApiConfig.Endpoint flows into the OpenAI-compatible adapter ({base}/v1/chat/completions),
  so any OpenAI-compatible gateway or self-hosted model works; the config list shows it.

Verified: ArchitectureTests 8/8, IntegrationTests 45/45 (new OrgStructureTests: spine
creation, kind tags, org-scoped validation 400s, Member 403), client build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-10 18:13:52 +03:30
parent 4416d99360
commit 1e65654114
15 changed files with 1153 additions and 21 deletions
+2
View File
@@ -9,6 +9,7 @@ import { OrgChartPage } from '@/pages/OrgChartPage'
import { PerformancePage } from '@/pages/PerformancePage' import { PerformancePage } from '@/pages/PerformancePage'
import { ReviewsPage } from '@/pages/ReviewsPage' import { ReviewsPage } from '@/pages/ReviewsPage'
import { SeatsPage } from '@/pages/SeatsPage' import { SeatsPage } from '@/pages/SeatsPage'
import { StructurePage } from '@/pages/StructurePage'
import { useAuth } from '@/store/auth' import { useAuth } from '@/store/auth'
export default function App() { export default function App() {
@@ -25,6 +26,7 @@ export default function App() {
<Route path="/cartable" element={token ? <CartablePage /> : <Navigate to="/login" replace />} /> <Route path="/cartable" element={token ? <CartablePage /> : <Navigate to="/login" replace />} />
<Route path="/members" element={token ? <MembersPage /> : <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="/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="/performance" element={token ? <PerformancePage /> : <Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
+2
View File
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
import { Link, useLocation } from 'react-router' import { Link, useLocation } from 'react-router'
import { import {
Bot, Bot,
Boxes,
ChartColumn, ChartColumn,
Gauge, Gauge,
Inbox, Inbox,
@@ -42,6 +43,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" /> <NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" />
<NavItem icon={Bot} label="AI seats" to="/seats" /> <NavItem icon={Bot} label="AI seats" to="/seats" />
<NavItem icon={Network} label="Org chart" to="/org" /> <NavItem icon={Network} label="Org chart" to="/org" />
<NavItem icon={Boxes} label="Structure" to="/structure" />
<NavItem icon={Users} label="Members" to="/members" /> <NavItem icon={Users} label="Members" to="/members" />
<NavItem icon={Gauge} label="Performance" to="/performance" /> <NavItem icon={Gauge} label="Performance" to="/performance" />
<NavItem icon={ChartColumn} label="Analytics" to="/analytics" /> <NavItem icon={ChartColumn} label="Analytics" to="/analytics" />
+88 -9
View File
@@ -7,18 +7,34 @@ import { api } from '@/lib/api'
import { useAuth } from '@/store/auth' import { useAuth } from '@/store/auth'
import type { SeatRow } from '@/lib/useDirectory' 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 { interface Team {
id: string id: string
organizationId: string organizationId: string
name: string name: string
productId: string | null
} }
const TEAM_WIDTH = 280 const TEAM_WIDTH = 280
const SEAT_HEIGHT = 64 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() { export function OrgChartPage() {
const organizationId = useAuth((s) => s.organizationId) const organizationId = useAuth((s) => s.organizationId)
const [divisions, setDivisions] = useState<Division[]>([])
const [products, setProducts] = useState<Product[]>([])
const [teams, setTeams] = useState<Team[]>([]) const [teams, setTeams] = useState<Team[]>([])
const [seatsByTeam, setSeatsByTeam] = useState<Record<string, SeatRow[]>>({}) const [seatsByTeam, setSeatsByTeam] = useState<Record<string, SeatRow[]>>({})
@@ -26,7 +42,13 @@ export function OrgChartPage() {
if (!organizationId) return if (!organizationId) return
void (async () => { void (async () => {
try { 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) setTeams(teamList)
const entries = await Promise.all( const entries = await Promise.all(
teamList.map(async (team) => { teamList.map(async (team) => {
@@ -44,7 +66,10 @@ export function OrgChartPage() {
})() })()
}, [organizationId]) }, [organizationId])
const { nodes, edges } = useMemo(() => buildGraph(teams, seatsByTeam), [teams, seatsByTeam]) const { nodes, edges } = useMemo(
() => buildGraph(divisions, products, teams, seatsByTeam),
[divisions, products, teams, seatsByTeam],
)
return ( return (
<AppShell> <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 nodes: Node[] = []
const edges: Edge[] = [] const edges: Edge[] = []
if (teams.length === 0) { if (teams.length === 0 && products.length === 0 && divisions.length === 0) {
return { nodes, edges } 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({ nodes.push({
id: 'org', 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) => { teams.forEach((team, teamIndex) => {
const x = teamIndex * TEAM_WIDTH const x = teamIndex * TEAM_WIDTH
teamX.set(team.id, x)
nodes.push({ nodes.push({
id: team.id, id: team.id,
position: { x, y: 110 }, position: { x, y: teamY },
data: { label: team.name }, data: { label: team.name },
style: { borderRadius: 10, fontWeight: 600, width: 200 }, style: { borderRadius: 10, fontWeight: 600, width: 200 },
}) })
edges.push({ id: `org-${team.id}`, source: 'org', target: team.id })
const seats = seatsByTeam[team.id] ?? [] const seats = seatsByTeam[team.id] ?? []
seats.forEach((seat, seatIndex) => { seats.forEach((seat, seatIndex) => {
const color = seat.state === 'Ai' ? '#4f46e5' : seat.state === 'Human' ? '#475569' : '#d97706' const color = seat.state === 'Ai' ? '#4f46e5' : seat.state === 'Human' ? '#475569' : '#d97706'
nodes.push({ nodes.push({
id: seat.id, 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}` }, data: { label: `${seat.roleName} · ${seat.state === 'Ai' ? 'AI' : seat.state}` },
style: { style: {
background: color, 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 } return { nodes, edges }
} }
+17 -5
View File
@@ -29,6 +29,7 @@ interface ApiConfig {
name: string name: string
provider: string provider: string
model: string model: string
endpoint: string | null
} }
interface Seat { interface Seat {
@@ -71,7 +72,7 @@ export function SeatsPage() {
const [seats, setSeats] = useState<Seat[]>([]) const [seats, setSeats] = useState<Seat[]>([])
const [skills, setSkills] = useState<Skill[]>([]) 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 [newSeat, setNewSeat] = useState('')
const [selectedSeat, setSelectedSeat] = useState<string | null>(null) const [selectedSeat, setSelectedSeat] = useState<string | null>(null)
const [agent, setAgent] = useState({ const [agent, setAgent] = useState({
@@ -115,8 +116,8 @@ export function SeatsPage() {
const createConfig = () => const createConfig = () =>
run(async () => { run(async () => {
await api.post('/api/integrations/api-configs', { organizationId, ...cfg }) await api.post('/api/integrations/api-configs', { organizationId, ...cfg, endpoint: cfg.endpoint.trim() || null })
setCfg({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '' }) setCfg({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '', endpoint: '' })
await loadConfigs() await loadConfigs()
toast.success('API config saved (key encrypted).') toast.success('API config saved (key encrypted).')
}) })
@@ -207,7 +208,7 @@ export function SeatsPage() {
<SelectTrigger className="w-36"><SelectValue /></SelectTrigger> <SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
{['stub', 'openai', 'anthropic', 'vertex', 'ollama'].map((p) => ( {['stub', 'openai', 'ollama', 'vllm', 'custom'].map((p) => (
<SelectItem key={p} value={p}>{p}</SelectItem> <SelectItem key={p} value={p}>{p}</SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
@@ -220,13 +221,24 @@ export function SeatsPage() {
<Field label="API key"> <Field label="API key">
<Input type="password" value={cfg.apiKey} onChange={(e) => setCfg({ ...cfg, apiKey: e.target.value })} className="w-44" placeholder="sk-…" /> <Input type="password" value={cfg.apiKey} onChange={(e) => setCfg({ ...cfg, apiKey: e.target.value })} className="w-44" placeholder="sk-…" />
</Field> </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> <Button onClick={createConfig}><Plus data-icon="inline-start" />Add</Button>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{configs.map((c) => ( {configs.map((c) => (
<div key={c.id} className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"> <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="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> <Button variant="outline" size="sm" onClick={() => testConfig(c.id)}>Test</Button>
</div> </div>
))} ))}
+260
View File
@@ -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; 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 internal sealed class Team : Entity
{ {
public Guid OrganizationId { get; private set; } public Guid OrganizationId { get; private set; }
public Guid? ProductId { get; private set; }
public string Name { get; private set; } = null!; public string Name { get; private set; } = null!;
public DateTimeOffset CreatedAtUtc { get; private set; } 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; OrganizationId = organizationId;
Name = name; Name = name;
CreatedAtUtc = createdAtUtc; 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 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); 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.MapGet("/ping", () => TypedResults.Ok(new ModulePing("orgboard")));
group.MapPost("/organizations", CreateOrganization).RequireAuthorization(); 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.MapPost("/teams", CreateTeam).RequireAuthorization();
group.MapGet("/teams", ListTeams).RequireAuthorization(); group.MapGet("/teams", ListTeams).RequireAuthorization();
group.MapPost("/tasks", CreateTask).RequireAuthorization(); group.MapPost("/tasks", CreateTask).RequireAuthorization();
@@ -87,11 +91,97 @@ internal static class OrgBoardEndpoints
return Results.BadRequest("Organization does not exist; create it first."); 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); db.Teams.Add(team);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("team.created", "Team", team.Id, user.MemberId, team.Name), 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( private static async Task<IResult> ListTeams(
@@ -105,7 +195,7 @@ internal static class OrgBoardEndpoints
var teams = await db.Teams var teams = await db.Teams
.Where(t => t.OrganizationId == organizationId) .Where(t => t.OrganizationId == organizationId)
.OrderBy(t => t.CreatedAtUtc) .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); .ToListAsync(ct);
return Results.Ok(teams); return Results.Ok(teams);
@@ -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
}
}
}
@@ -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");
}
}
}
@@ -75,6 +75,30 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
b.ToTable("agents", "orgboard"); 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 => modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -94,6 +118,40 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
b.ToTable("organizations", "orgboard"); 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 => modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -146,10 +204,15 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
b.Property<Guid>("OrganizationId") b.Property<Guid>("OrganizationId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<Guid?>("ProductId")
.HasColumnType("uuid");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("OrganizationId"); b.HasIndex("OrganizationId");
b.HasIndex("ProductId");
b.ToTable("teams", "orgboard"); b.ToTable("teams", "orgboard");
}); });
@@ -8,6 +8,8 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
: DbContext(options), IModuleDbContext : DbContext(options), IModuleDbContext
{ {
public DbSet<Organization> Organizations => Set<Organization>(); 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<Team> Teams => Set<Team>();
public DbSet<Seat> Seats => Set<Seat>(); public DbSet<Seat> Seats => Set<Seat>();
public DbSet<Agent> Agents => Set<Agent>(); 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(); 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 => modelBuilder.Entity<Team>(team =>
{ {
team.ToTable("teams"); team.ToTable("teams");
team.HasKey(t => t.Id); team.HasKey(t => t.Id);
team.Property(t => t.Name).HasMaxLength(200).IsRequired(); team.Property(t => t.Name).HasMaxLength(200).IsRequired();
team.HasIndex(t => t.OrganizationId); team.HasIndex(t => t.OrganizationId);
team.HasIndex(t => t.ProductId);
}); });
modelBuilder.Entity<Seat>(seat => 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!;
}
}