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 { 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,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" />
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+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");
|
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!;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user