diff --git a/client/src/App.tsx b/client/src/App.tsx index 088e621..4439503 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -9,6 +9,7 @@ import { OrgChartPage } from '@/pages/OrgChartPage' import { PerformancePage } from '@/pages/PerformancePage' import { ReviewsPage } from '@/pages/ReviewsPage' import { SeatsPage } from '@/pages/SeatsPage' +import { StructurePage } from '@/pages/StructurePage' import { useAuth } from '@/store/auth' export default function App() { @@ -25,6 +26,7 @@ export default function App() { : } /> : } /> : } /> + : } /> : } /> } /> diff --git a/client/src/components/AppShell.tsx b/client/src/components/AppShell.tsx index abcd402..cdbddef 100644 --- a/client/src/components/AppShell.tsx +++ b/client/src/components/AppShell.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react' import { Link, useLocation } from 'react-router' import { Bot, + Boxes, ChartColumn, Gauge, Inbox, @@ -42,6 +43,7 @@ export function AppShell({ children }: { children: ReactNode }) { + diff --git a/client/src/pages/OrgChartPage.tsx b/client/src/pages/OrgChartPage.tsx index 52625fe..0c928e4 100644 --- a/client/src/pages/OrgChartPage.tsx +++ b/client/src/pages/OrgChartPage.tsx @@ -7,18 +7,34 @@ import { api } from '@/lib/api' import { useAuth } from '@/store/auth' import type { SeatRow } from '@/lib/useDirectory' +interface Division { + id: string + name: string +} + +interface Product { + id: string + divisionId: string | null + name: string + kind: string +} + interface Team { id: string organizationId: string name: string + productId: string | null } const TEAM_WIDTH = 280 const SEAT_HEIGHT = 64 +const LAYER_HEIGHT = 100 -/** The live org chart: org → teams → seats, painted with the human/open/AI triad. */ +/** The live org chart: org → divisions → products → teams → seats, painted with the triad. */ export function OrgChartPage() { const organizationId = useAuth((s) => s.organizationId) + const [divisions, setDivisions] = useState([]) + const [products, setProducts] = useState([]) const [teams, setTeams] = useState([]) const [seatsByTeam, setSeatsByTeam] = useState>({}) @@ -26,7 +42,13 @@ export function OrgChartPage() { if (!organizationId) return void (async () => { try { - const teamList = await api.get(`/api/orgboard/teams?organizationId=${organizationId}`) + const [divisionList, productList, teamList] = await Promise.all([ + api.get(`/api/orgboard/divisions?organizationId=${organizationId}`), + api.get(`/api/orgboard/products?organizationId=${organizationId}`), + api.get(`/api/orgboard/teams?organizationId=${organizationId}`), + ]) + setDivisions(divisionList) + setProducts(productList) setTeams(teamList) const entries = await Promise.all( teamList.map(async (team) => { @@ -44,7 +66,10 @@ export function OrgChartPage() { })() }, [organizationId]) - const { nodes, edges } = useMemo(() => buildGraph(teams, seatsByTeam), [teams, seatsByTeam]) + const { nodes, edges } = useMemo( + () => buildGraph(divisions, products, teams, seatsByTeam), + [divisions, products, teams, seatsByTeam], + ) return ( @@ -67,14 +92,26 @@ export function OrgChartPage() { ) } -function buildGraph(teams: Team[], seatsByTeam: Record): { nodes: Node[]; edges: Edge[] } { +function buildGraph( + divisions: Division[], + products: Product[], + teams: Team[], + seatsByTeam: Record, +): { nodes: Node[]; edges: Edge[] } { const nodes: Node[] = [] const edges: Edge[] = [] - if (teams.length === 0) { + if (teams.length === 0 && products.length === 0 && divisions.length === 0) { return { nodes, edges } } - const totalWidth = teams.length * TEAM_WIDTH + const hasDivisions = divisions.length > 0 + const hasProducts = products.length > 0 + const divisionY = LAYER_HEIGHT + const productY = divisionY + (hasDivisions ? LAYER_HEIGHT : 0) + const teamY = productY + (hasProducts ? LAYER_HEIGHT : 0) + const seatY = teamY + LAYER_HEIGHT + + const totalWidth = Math.max(teams.length, products.length, divisions.length, 1) * TEAM_WIDTH nodes.push({ id: 'org', @@ -90,22 +127,24 @@ function buildGraph(teams: Team[], seatsByTeam: Record): { no }, }) + // Teams anchor the columns; seats stack underneath each team. + const teamX = new Map() teams.forEach((team, teamIndex) => { const x = teamIndex * TEAM_WIDTH + teamX.set(team.id, x) nodes.push({ id: team.id, - position: { x, y: 110 }, + position: { x, y: teamY }, data: { label: team.name }, style: { borderRadius: 10, fontWeight: 600, width: 200 }, }) - edges.push({ id: `org-${team.id}`, source: 'org', target: team.id }) const seats = seatsByTeam[team.id] ?? [] seats.forEach((seat, seatIndex) => { const color = seat.state === 'Ai' ? '#4f46e5' : seat.state === 'Human' ? '#475569' : '#d97706' nodes.push({ id: seat.id, - position: { x: x + 10, y: 210 + seatIndex * SEAT_HEIGHT }, + position: { x: x + 10, y: seatY + seatIndex * SEAT_HEIGHT }, data: { label: `${seat.roleName} · ${seat.state === 'Ai' ? 'AI' : seat.state}` }, style: { background: color, @@ -120,5 +159,45 @@ function buildGraph(teams: Team[], seatsByTeam: Record): { no }) }) + // Products sit above their teams (centered); parentless ones get slots after the team row. + const productX = new Map() + 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() + 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 } } diff --git a/client/src/pages/SeatsPage.tsx b/client/src/pages/SeatsPage.tsx index f473aaf..3017aaa 100644 --- a/client/src/pages/SeatsPage.tsx +++ b/client/src/pages/SeatsPage.tsx @@ -29,6 +29,7 @@ interface ApiConfig { name: string provider: string model: string + endpoint: string | null } interface Seat { @@ -71,7 +72,7 @@ export function SeatsPage() { const [seats, setSeats] = useState([]) const [skills, setSkills] = useState([]) - const [cfg, setCfg] = useState({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '' }) + const [cfg, setCfg] = useState({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '', endpoint: '' }) const [newSeat, setNewSeat] = useState('') const [selectedSeat, setSelectedSeat] = useState(null) const [agent, setAgent] = useState({ @@ -115,8 +116,8 @@ export function SeatsPage() { const createConfig = () => run(async () => { - await api.post('/api/integrations/api-configs', { organizationId, ...cfg }) - setCfg({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '' }) + await api.post('/api/integrations/api-configs', { organizationId, ...cfg, endpoint: cfg.endpoint.trim() || null }) + setCfg({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '', endpoint: '' }) await loadConfigs() toast.success('API config saved (key encrypted).') }) @@ -207,7 +208,7 @@ export function SeatsPage() { - {['stub', 'openai', 'anthropic', 'vertex', 'ollama'].map((p) => ( + {['stub', 'openai', 'ollama', 'vllm', 'custom'].map((p) => ( {p} ))} @@ -220,13 +221,24 @@ export function SeatsPage() { setCfg({ ...cfg, apiKey: e.target.value })} className="w-44" placeholder="sk-…" /> + + setCfg({ ...cfg, endpoint: e.target.value })} + className="w-72" + placeholder="https://my-gateway.example.com" + /> +
{configs.map((c) => (
{c.name} - {c.provider} · {c.model} + + {c.provider} · {c.model} + {c.endpoint ? ` · ${c.endpoint}` : ''} +
))} diff --git a/client/src/pages/StructurePage.tsx b/client/src/pages/StructurePage.tsx new file mode 100644 index 0000000..e98bacd --- /dev/null +++ b/client/src/pages/StructurePage.tsx @@ -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([]) + const [products, setProducts] = useState([]) + const [teams, setTeams] = useState([]) + 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(`/api/orgboard/divisions?organizationId=${organizationId}`), + api.get(`/api/orgboard/products?organizationId=${organizationId}`), + api.get(`/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) => { + 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 ( + +
+
+

+ Structure +

+

+ The object spine: organization → divisions → products/services → teams. +

+
+ +
+ + + Divisions + Technical, Finance, HR, Sales — the top-level slices. + + +
+ + setDivisionName(e.target.value)} className="w-56" placeholder="Technical" /> + + +
+
+ {divisions.map((d) => ( + {d.name} + ))} + {divisions.length === 0 &&

No divisions yet — optional, but they unlock the full org chart.

} +
+
+
+ + + + Products & services + Engineering divisions ship products; other divisions run services. + + +
+ + setProduct({ ...product, name: e.target.value })} className="w-48" placeholder="IPNOPS" /> + + + + + + + + +
+
+ {products.map((p) => ( +
+ {p.name} + {p.kind} + + {p.divisionId ? divisions.find((d) => d.id === p.divisionId)?.name ?? '' : 'no division'} + +
+ ))} + {products.length === 0 &&

No products yet.

} +
+
+
+ + + + Teams + Teams run delivery. Attach them to a product to complete the spine. + + +
+ + setTeam({ ...team, name: e.target.value })} className="w-48" placeholder="Core team" /> + + + + + +
+
+ {teams.map((t) => ( +
+ {t.name} + + {t.productId ? products.find((p) => p.id === t.productId)?.name ?? '' : 'directly under the org'} + +
+ ))} + {teams.length === 0 &&

No teams yet.

} +
+
+
+
+
+
+ ) +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Division.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Division.cs new file mode 100644 index 0000000..cb3fc3b --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Division.cs @@ -0,0 +1,22 @@ +using TeamUp.SharedKernel.Domain; + +namespace TeamUp.Modules.OrgBoard.Domain; + +/// An organizational division (Technical, Finance, HR, …). Products/services live under it. +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; + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Product.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Product.cs new file mode 100644 index 0000000..9aa7f61 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Product.cs @@ -0,0 +1,33 @@ +using TeamUp.SharedKernel.Domain; + +namespace TeamUp.Modules.OrgBoard.Domain; + +/// Engineering divisions organize around products; other divisions around services. +internal enum ProductKind +{ + Product, + Service, +} + +/// A product or service — the same entity with a kind tag. Teams live under it. +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; + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Team.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Team.cs index 903df10..a20a03e 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Team.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Team.cs @@ -2,10 +2,14 @@ using TeamUp.SharedKernel.Domain; namespace TeamUp.Modules.OrgBoard.Domain; -/// A team within an organization. Team-level memberships are granted at its id (Team scope). +/// +/// 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. +/// internal sealed class Team : Entity { public Guid OrganizationId { get; private set; } + public Guid? ProductId { get; private set; } public string Name { get; private set; } = null!; public DateTimeOffset CreatedAtUtc { get; private set; } @@ -13,10 +17,11 @@ internal sealed class Team : Entity { } - public Team(Guid organizationId, string name, DateTimeOffset createdAtUtc) + public Team(Guid organizationId, string name, DateTimeOffset createdAtUtc, Guid? productId = null) { OrganizationId = organizationId; Name = name; CreatedAtUtc = createdAtUtc; + ProductId = productId; } } diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs index dd58e90..5b3e933 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs @@ -7,9 +7,17 @@ internal sealed record CreateOrganizationRequest(Guid OrganizationId, string Nam internal sealed record OrganizationResponse(Guid Id, string Name); -internal sealed record CreateTeamRequest(Guid OrganizationId, string Name); +internal sealed record CreateTeamRequest(Guid OrganizationId, string Name, Guid? ProductId = null); -internal sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name); +internal sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name, Guid? ProductId = null); + +internal sealed record CreateDivisionRequest(Guid OrganizationId, string Name); + +internal sealed record DivisionResponse(Guid Id, Guid OrganizationId, string Name); + +internal sealed record CreateProductRequest(Guid OrganizationId, string Name, ProductKind Kind, Guid? DivisionId = null); + +internal sealed record ProductResponse(Guid Id, Guid OrganizationId, Guid? DivisionId, string Name, string Kind); internal sealed record CreateTaskRequest(Guid TeamId, string Title, string? Description, WorkItemType Type); diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs index 0317fab..fd75365 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs @@ -18,6 +18,10 @@ internal static class OrgBoardEndpoints group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("orgboard"))); group.MapPost("/organizations", CreateOrganization).RequireAuthorization(); + group.MapPost("/divisions", CreateDivision).RequireAuthorization(); + group.MapGet("/divisions", ListDivisions).RequireAuthorization(); + group.MapPost("/products", CreateProduct).RequireAuthorization(); + group.MapGet("/products", ListProducts).RequireAuthorization(); group.MapPost("/teams", CreateTeam).RequireAuthorization(); group.MapGet("/teams", ListTeams).RequireAuthorization(); group.MapPost("/tasks", CreateTask).RequireAuthorization(); @@ -87,11 +91,97 @@ internal static class OrgBoardEndpoints return Results.BadRequest("Organization does not exist; create it first."); } - var team = new Team(request.OrganizationId, request.Name.Trim(), clock.GetUtcNow()); + if (request.ProductId is { } productId + && !await db.Products.AnyAsync(p => p.Id == productId && p.OrganizationId == request.OrganizationId, ct)) + { + return Results.BadRequest("Product not found in this organization."); + } + + var team = new Team(request.OrganizationId, request.Name.Trim(), clock.GetUtcNow(), request.ProductId); db.Teams.Add(team); await db.SaveChangesAsync(ct); await audit.WriteAsync(new AuditEvent("team.created", "Team", team.Id, user.MemberId, team.Name), ct); - return Results.Ok(new TeamResponse(team.Id, team.OrganizationId, team.Name)); + return Results.Ok(new TeamResponse(team.Id, team.OrganizationId, team.Name, team.ProductId)); + } + + private static async Task 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 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 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 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 ListTeams( @@ -105,7 +195,7 @@ internal static class OrgBoardEndpoints var teams = await db.Teams .Where(t => t.OrganizationId == organizationId) .OrderBy(t => t.CreatedAtUtc) - .Select(t => new TeamResponse(t.Id, t.OrganizationId, t.Name)) + .Select(t => new TeamResponse(t.Id, t.OrganizationId, t.Name, t.ProductId)) .ToListAsync(ct); return Results.Ok(teams); diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260610142338_AddDivisionsAndProducts.Designer.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260610142338_AddDivisionsAndProducts.Designer.cs new file mode 100644 index 0000000..4d2f6f9 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260610142338_AddDivisionsAndProducts.Designer.cs @@ -0,0 +1,317 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiConfigId") + .HasColumnType("uuid"); + + b.Property("Autonomy") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Docs") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("FallbackApiConfigId") + .HasColumnType("uuid"); + + b.Property("Monogram") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeatId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("SkillKeys") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("divisions", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DivisionId") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MemberId") + .HasColumnType("uuid"); + + b.Property("RoleName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TeamId"); + + b.ToTable("seats", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("AssigneeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByMemberId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorMemberId") + .HasColumnType("uuid"); + + b.Property("FromStatus") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.Property("ToStatus") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TeamId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("work_item_transitions", "orgboard"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260610142338_AddDivisionsAndProducts.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260610142338_AddDivisionsAndProducts.cs new file mode 100644 index 0000000..d405c4d --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260610142338_AddDivisionsAndProducts.cs @@ -0,0 +1,100 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeamUp.Modules.OrgBoard.Persistence.Migrations +{ + /// + public partial class AddDivisionsAndProducts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ProductId", + schema: "orgboard", + table: "teams", + type: "uuid", + nullable: true); + + migrationBuilder.CreateTable( + name: "divisions", + schema: "orgboard", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OrganizationId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + CreatedAtUtc = table.Column(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(type: "uuid", nullable: false), + OrganizationId = table.Column(type: "uuid", nullable: false), + DivisionId = table.Column(type: "uuid", nullable: true), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Kind = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + CreatedAtUtc = table.Column(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"); + } + + /// + 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"); + } + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs index d752452..3e4c313 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs @@ -75,6 +75,30 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations b.ToTable("agents", "orgboard"); }); + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Division", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("divisions", "orgboard"); + }); + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b => { b.Property("Id") @@ -94,6 +118,40 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations b.ToTable("organizations", "orgboard"); }); + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DivisionId") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("Id") @@ -146,10 +204,15 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations b.Property("OrganizationId") .HasColumnType("uuid"); + b.Property("ProductId") + .HasColumnType("uuid"); + b.HasKey("Id"); b.HasIndex("OrganizationId"); + b.HasIndex("ProductId"); + b.ToTable("teams", "orgboard"); }); diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs index 082d1ec..2ec80bf 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs @@ -8,6 +8,8 @@ internal sealed class OrgBoardDbContext(DbContextOptions opti : DbContext(options), IModuleDbContext { public DbSet Organizations => Set(); + public DbSet Divisions => Set(); + public DbSet Products => Set(); public DbSet Teams => Set(); public DbSet Seats => Set(); public DbSet Agents => Set(); @@ -25,12 +27,31 @@ internal sealed class OrgBoardDbContext(DbContextOptions opti organization.Property(o => o.Name).HasMaxLength(200).IsRequired(); }); + modelBuilder.Entity(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.ToTable("products"); + product.HasKey(p => p.Id); + product.Property(p => p.Name).HasMaxLength(200).IsRequired(); + product.Property(p => p.Kind).HasConversion().HasMaxLength(16); + product.HasIndex(p => p.OrganizationId); + product.HasIndex(p => p.DivisionId); + }); + modelBuilder.Entity(team => { team.ToTable("teams"); team.HasKey(t => t.Id); team.Property(t => t.Name).HasMaxLength(200).IsRequired(); team.HasIndex(t => t.OrganizationId); + team.HasIndex(t => t.ProductId); }); modelBuilder.Entity(seat => diff --git a/tests/TeamUp.IntegrationTests/OrgStructureTests.cs b/tests/TeamUp.IntegrationTests/OrgStructureTests.cs new file mode 100644 index 0000000..2b49f52 --- /dev/null +++ b/tests/TeamUp.IntegrationTests/OrgStructureTests.cs @@ -0,0 +1,118 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Xunit; + +namespace TeamUp.IntegrationTests; + +/// +/// 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). +/// +public sealed class OrgStructureTests(PostgresFixture postgres) : IClassFixture +{ + 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(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(client, "/api/orgboard/divisions", + new { organizationId = owner.OrganizationId, name = "Technical" }); + + var ipnops = await PostOk(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(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>( + $"/api/orgboard/divisions?organizationId={owner.OrganizationId}"); + Assert.Contains(divisions!, d => d.Name == "Technical"); + + var products = await client.GetFromJsonAsync>( + $"/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(client, "/api/orgboard/teams", + new { organizationId = owner.OrganizationId, name = "Core", productId = ipnops.Id }); + Assert.Equal(ipnops.Id, coreTeam.ProductId); + + var looseTeam = await PostOk(client, "/api/orgboard/teams", + new { organizationId = owner.OrganizationId, name = "Loose" }); + Assert.Null(looseTeam.ProductId); + + var teams = await client.GetFromJsonAsync>( + $"/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(client, "/api/identity/invitations", new + { + email = "dev@alia.test", + scopeType = "Organization", + scopeId = owner.OrganizationId, + role = "Member", + organizationId = owner.OrganizationId, + }); + var member = await PostOk(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 PostOk(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(); + Assert.NotNull(value); + return value!; + } +}