Theme 2: cross-division delivery pipeline (change requests)

A customer change request now flows through a guarded commercial pipeline:
Requested -> Estimated -> Approved -> Paid -> Live. The cross-division work and
its dependencies live on the request's steps (a division's slice + hours +
an optional depends-on link), and estimating sums the steps into a total. Each
transition is guarded on the ChangeRequest aggregate, so it can only move
forward in order; guard violations surface as 400s.

- Domain: ChangeRequest + ChangeRequestStep aggregates with stage guards
- Persistence: two tables + EF migration (applied)
- Endpoints under /api/orgboard/change-requests: create/list/detail, add/advance
  steps, and estimate/approve/pay/go-live/reject (reads need board-view,
  commercial actions are owner-level)
- New Delivery pipeline page: request list with stage + step progress, a detail
  drawer with a stage stepper, the next commercial action, quote entry, and a
  per-division step breakdown with dependencies

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-17 07:47:57 +03:30
parent 5c2b697b66
commit c12935ad74
12 changed files with 1816 additions and 0 deletions
+2
View File
@@ -11,6 +11,7 @@ import { LoginPage } from '@/pages/LoginPage'
import { MembersPage } from '@/pages/MembersPage'
import { OrgChartPage } from '@/pages/OrgChartPage'
import { PerformancePage } from '@/pages/PerformancePage'
import { PipelinePage } from '@/pages/PipelinePage'
import { ProductProfilesPage } from '@/pages/ProductProfilesPage'
import { ReviewsPage } from '@/pages/ReviewsPage'
import { SeatsPage } from '@/pages/SeatsPage'
@@ -33,6 +34,7 @@ export default function App() {
<Route path="/reviews" element={token ? <ReviewsPage /> : <Navigate to="/login" replace />} />
<Route path="/analytics" element={token ? <AnalyticsPage /> : <Navigate to="/login" replace />} />
<Route path="/delivery" element={token ? <DeliveryPage /> : <Navigate to="/login" replace />} />
<Route path="/pipeline" element={token ? <PipelinePage /> : <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="/org" element={token ? <OrgChartPage /> : <Navigate to="/login" replace />} />
+2
View File
@@ -19,6 +19,7 @@ import {
Sparkles,
TrendingUp,
Users,
Workflow,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
@@ -100,6 +101,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<NavItem icon={LayoutDashboard} label="Board" to="/" color="#38bdf8" />
<NavItem icon={Sparkles} label="Team" to="/team" color="#38bdf8" />
<NavItem icon={Inbox} label="Cartable" to="/cartable" color="#38bdf8" />
<NavItem icon={Workflow} label="Delivery pipeline" to="/pipeline" color="#38bdf8" />
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" color="#38bdf8" badge={reviewCount} />
<NavSection label="Organization" />
+483
View File
@@ -0,0 +1,483 @@
import { useCallback, useEffect, useState } from 'react'
import { ArrowRight, Check, GitBranch, Plus, Workflow } 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 } 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 {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Textarea } from '@/components/ui/textarea'
import { api } from '@/lib/api'
import { useAuth } from '@/store/auth'
interface Division {
id: string
name: string
}
interface ChangeSummary {
id: string
customerName: string
title: string
status: string
estimateHours: number | null
amount: number | null
currency: string
stepCount: number
doneStepCount: number
}
interface ChangeStep {
id: string
divisionId: string | null
divisionName: string | null
title: string
estimateHours: number
status: string
dependsOnStepId: string | null
order: number
}
interface ChangeDetail {
summary: ChangeSummary
description: string | null
totalStepHours: number
steps: ChangeStep[]
}
const STAGES = ['Requested', 'Estimated', 'Approved', 'Paid', 'Live'] as const
const STEP_STATUSES = ['Pending', 'InProgress', 'Done', 'Blocked'] as const
// The single commercial action available at each pipeline stage.
const ACTION: Record<string, { label: string; path: string } | null> = {
Requested: { label: 'Send estimate', path: 'estimate' },
Estimated: { label: 'Mark approved', path: 'approve' },
Approved: { label: 'Record payment', path: 'pay' },
Paid: { label: 'Go live', path: 'go-live' },
Live: null,
Rejected: null,
}
const STATUS_TONE: Record<string, string> = {
Requested: 'bg-muted text-muted-foreground',
Estimated: 'bg-sky-500/15 text-sky-700 dark:text-sky-400',
Approved: 'bg-violet-500/15 text-violet-700 dark:text-violet-400',
Paid: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
Live: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
Rejected: 'bg-destructive/15 text-destructive',
}
export function PipelinePage() {
const organizationId = useAuth((s) => s.organizationId)
const [requests, setRequests] = useState<ChangeSummary[]>([])
const [divisions, setDivisions] = useState<Division[]>([])
const [openId, setOpenId] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const load = useCallback(async () => {
if (!organizationId) return
try {
const [list, divs] = await Promise.all([
api.get<ChangeSummary[]>(`/api/orgboard/change-requests?organizationId=${organizationId}`),
api.get<Division[]>(`/api/orgboard/divisions?organizationId=${organizationId}`).catch(() => []),
])
setRequests(list)
setDivisions(divs)
} catch (err) {
toast.error((err as Error).message)
}
}, [organizationId])
useEffect(() => {
void load()
}, [load])
return (
<AppShell>
<div className="mx-auto max-w-4xl p-6">
<header className="mb-5 flex items-center justify-between gap-4">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Workflow className="size-6" /> Delivery pipeline
</h1>
<p className="text-sm text-muted-foreground">
Customer change requests across divisions: estimate approve pay go-live.
</p>
</div>
<Button size="sm" onClick={() => setCreating((v) => !v)}>
<Plus data-icon="inline-start" /> New request
</Button>
</header>
{creating && organizationId && (
<NewRequestForm
organizationId={organizationId}
onCreated={() => {
setCreating(false)
void load()
}}
/>
)}
{/* Pipeline legend */}
<div className="mb-5 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
{STAGES.map((s, i) => (
<span key={s} className="flex items-center gap-1.5">
<span className="rounded-full bg-muted px-2 py-0.5 font-medium">{s}</span>
{i < STAGES.length - 1 && <ArrowRight className="size-3" />}
</span>
))}
</div>
<div className="flex flex-col gap-2">
{requests.map((cr) => {
const pct = cr.stepCount > 0 ? Math.round((cr.doneStepCount / cr.stepCount) * 100) : 0
return (
<button
key={cr.id}
type="button"
onClick={() => setOpenId(cr.id)}
className="flex items-center gap-3 rounded-xl border bg-card/60 px-4 py-3 text-left backdrop-blur-sm transition-colors hover:border-ring/60"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{cr.title}</span>
<span className={`shrink-0 rounded-md px-2 py-0.5 text-xs font-medium ${STATUS_TONE[cr.status] ?? 'bg-muted'}`}>
{cr.status}
</span>
</div>
<p className="truncate text-xs text-muted-foreground">
{cr.customerName}
{cr.estimateHours != null && ` · ${cr.estimateHours}h`}
{cr.amount != null && ` · ${cr.amount.toLocaleString()} ${cr.currency}`}
{cr.stepCount > 0 && ` · ${cr.doneStepCount}/${cr.stepCount} steps`}
</p>
</div>
{cr.stepCount > 0 && (
<div className="hidden h-2 w-24 shrink-0 overflow-hidden rounded-full bg-muted/60 sm:block">
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${pct}%` }} />
</div>
)}
</button>
)
})}
{requests.length === 0 && (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
No change requests yet. Log a customer ask to start the pipeline.
</CardContent>
</Card>
)}
</div>
</div>
{openId && (
<RequestDrawer
id={openId}
divisions={divisions}
onClose={() => setOpenId(null)}
onChanged={load}
/>
)}
</AppShell>
)
}
function NewRequestForm({ organizationId, onCreated }: { organizationId: string; onCreated: () => void }) {
const [customer, setCustomer] = useState('')
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [busy, setBusy] = useState(false)
async function submit() {
if (!customer.trim() || !title.trim()) {
toast.error('Customer and title are required.')
return
}
setBusy(true)
try {
await api.post('/api/orgboard/change-requests', { organizationId, customerName: customer, title, description })
toast.success('Change request logged.')
onCreated()
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
return (
<div className="mb-5 flex flex-col gap-3 rounded-xl border bg-card/60 p-4 backdrop-blur-sm">
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label>Customer</Label>
<Input value={customer} onChange={(e) => setCustomer(e.target.value)} placeholder="Acme Corp" />
</div>
<div className="flex flex-col gap-1.5">
<Label>Title</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Add SSO to the portal" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label>What they're asking for</Label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
</div>
<div className="flex justify-end">
<Button size="sm" disabled={busy} onClick={submit}>Log request</Button>
</div>
</div>
)
}
function RequestDrawer({
id,
divisions,
onClose,
onChanged,
}: {
id: string
divisions: Division[]
onClose: () => void
onChanged: () => void
}) {
const [detail, setDetail] = useState<ChangeDetail | null>(null)
const [busy, setBusy] = useState(false)
const [amount, setAmount] = useState('')
const [currency, setCurrency] = useState('USD')
const reload = useCallback(async () => {
try {
setDetail(await api.get<ChangeDetail>(`/api/orgboard/change-requests/${id}`))
} catch (err) {
toast.error((err as Error).message)
}
}, [id])
useEffect(() => {
void reload()
}, [reload])
async function act(fn: () => Promise<ChangeDetail>, success?: string) {
setBusy(true)
try {
setDetail(await fn())
if (success) toast.success(success)
onChanged()
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
const status = detail?.summary.status ?? 'Requested'
const action = ACTION[status]
const stageIndex = STAGES.indexOf(status as (typeof STAGES)[number])
return (
<Sheet open onOpenChange={(open) => !open && onClose()}>
<SheetContent className="overflow-y-auto sm:max-w-xl">
{detail && (
<>
<SheetHeader>
<div className="flex items-center gap-2">
<span className={`rounded-md px-2 py-0.5 text-xs font-medium ${STATUS_TONE[status] ?? 'bg-muted'}`}>{status}</span>
<Badge variant="outline">{detail.summary.customerName}</Badge>
</div>
<SheetTitle>{detail.summary.title}</SheetTitle>
{detail.description && <SheetDescription>{detail.description}</SheetDescription>}
</SheetHeader>
{/* Stage stepper */}
<div className="flex items-center gap-1 px-4">
{STAGES.map((s, i) => {
const done = stageIndex >= 0 && i <= stageIndex
return (
<div key={s} className="flex flex-1 flex-col items-center gap-1">
<div className="flex w-full items-center">
<span className={`grid size-6 shrink-0 place-items-center rounded-full border text-[10px] ${done ? 'border-emerald-500 bg-emerald-500/15 text-emerald-600' : 'border-border bg-muted/40 text-muted-foreground'}`}>
{done ? <Check className="size-3" /> : i + 1}
</span>
{i < STAGES.length - 1 && <span className={`h-0.5 flex-1 ${stageIndex > i ? 'bg-emerald-500' : 'bg-border'}`} />}
</div>
<span className="text-[10px] text-muted-foreground">{s}</span>
</div>
)
})}
</div>
{/* Totals + commercial action */}
<div className="mx-4 flex flex-col gap-3 rounded-xl border bg-card/60 p-4 backdrop-blur-sm">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Total estimate</span>
<span className="font-semibold">{detail.totalStepHours}h{detail.summary.amount != null && ` · ${detail.summary.amount.toLocaleString()} ${detail.summary.currency}`}</span>
</div>
{status === 'Requested' && (
<div className="flex items-end gap-2">
<div className="flex flex-1 flex-col gap-1">
<Label className="text-xs">Quote amount (optional)</Label>
<Input value={amount} onChange={(e) => setAmount(e.target.value)} inputMode="decimal" placeholder="12000" />
</div>
<div className="flex w-24 flex-col gap-1">
<Label className="text-xs">Currency</Label>
<Input value={currency} onChange={(e) => setCurrency(e.target.value)} />
</div>
</div>
)}
{action && (
<Button
disabled={busy}
onClick={() =>
act(
() =>
api.post<ChangeDetail>(`/api/orgboard/change-requests/${id}/${action.path}`,
action.path === 'estimate'
? { amount: amount ? Number(amount) : null, currency }
: {}),
`${action.label} done.`,
)
}
>
{action.label} <ArrowRight data-icon="inline-end" />
</Button>
)}
{status !== 'Live' && status !== 'Rejected' && (
<Button
variant="ghost"
size="sm"
disabled={busy}
className="text-destructive hover:text-destructive"
onClick={() => act(() => api.post<ChangeDetail>(`/api/orgboard/change-requests/${id}/reject`, {}), 'Rejected.')}
>
Reject request
</Button>
)}
</div>
{/* Steps */}
<div className="flex flex-col gap-2 px-4 pb-6">
<Label className="flex items-center gap-1.5"><GitBranch className="size-4" /> Cross-division steps</Label>
{detail.steps.map((step) => (
<div key={step.id} className="flex items-center gap-2 rounded-lg border bg-card/50 px-3 py-2 text-sm backdrop-blur-sm">
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{step.title}</p>
<p className="text-xs text-muted-foreground">
{step.divisionName ?? 'Unassigned'} · {step.estimateHours}h
{step.dependsOnStepId && ' · depends on a prior step'}
</p>
</div>
<Select value={step.status} onValueChange={(v) => act(() => api.patch<ChangeDetail>(`/api/orgboard/change-requests/${id}/steps/${step.id}`, { status: v }))}>
<SelectTrigger className="h-8 w-32 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
{STEP_STATUSES.map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
</div>
))}
{detail.steps.length === 0 && <p className="text-xs text-muted-foreground">No steps yet — break the work down by division below.</p>}
<AddStepForm id={id} divisions={divisions} steps={detail.steps} onAdded={(d) => setDetail(d)} />
</div>
</>
)}
</SheetContent>
</Sheet>
)
}
function AddStepForm({
id,
divisions,
steps,
onAdded,
}: {
id: string
divisions: Division[]
steps: ChangeStep[]
onAdded: (detail: ChangeDetail) => void
}) {
const [title, setTitle] = useState('')
const [hours, setHours] = useState('')
const [divisionId, setDivisionId] = useState<string>('')
const [dependsOn, setDependsOn] = useState<string>('')
const [busy, setBusy] = useState(false)
async function submit() {
if (!title.trim() || !hours) {
toast.error('Step title and hours are required.')
return
}
setBusy(true)
try {
const detail = await api.post<ChangeDetail>(`/api/orgboard/change-requests/${id}/steps`, {
title,
estimateHours: Number(hours),
divisionId: divisionId || null,
dependsOnStepId: dependsOn || null,
})
onAdded(detail)
setTitle('')
setHours('')
setDivisionId('')
setDependsOn('')
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
return (
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-dashed p-3">
<div className="flex gap-2">
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Step (e.g. Build SSO connector)" className="flex-1" />
<Input value={hours} onChange={(e) => setHours(e.target.value)} inputMode="decimal" placeholder="Hours" className="w-24" />
</div>
<div className="flex gap-2">
<Select value={divisionId} onValueChange={setDivisionId}>
<SelectTrigger className="flex-1"><SelectValue placeholder="Division (optional)" /></SelectTrigger>
<SelectContent>
<SelectGroup>
{divisions.map((d) => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
{steps.length > 0 && (
<Select value={dependsOn} onValueChange={setDependsOn}>
<SelectTrigger className="flex-1"><SelectValue placeholder="Depends on (optional)" /></SelectTrigger>
<SelectContent>
<SelectGroup>
{steps.map((s) => <SelectItem key={s.id} value={s.id}>{s.title}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
)}
</div>
<div className="flex justify-end">
<Button size="sm" variant="outline" disabled={busy} onClick={submit}>
<Plus data-icon="inline-start" /> Add step
</Button>
</div>
</div>
)
}
@@ -0,0 +1,113 @@
using TeamUp.SharedKernel.Domain;
namespace TeamUp.Modules.OrgBoard.Domain;
/// <summary>Where a customer change request sits in the commercial delivery pipeline.</summary>
internal enum ChangeRequestStatus
{
Requested, // logged from the customer, not yet scoped
Estimated, // hours + price quoted, awaiting the customer's decision
Approved, // customer approved the quote — cleared to schedule work
Paid, // payment received — cleared to go live
Live, // delivered / live for the customer
Rejected, // customer declined, or we won't do it
}
/// <summary>
/// A customer change request flowing across divisions: logged → estimated (total hours + price) →
/// approved → paid → live. The cross-division work and its dependencies live on the request's steps
/// (<see cref="ChangeRequestStep"/>); the request itself owns the commercial pipeline and its guards —
/// you can't approve before estimating, take payment before approval, or go live before payment.
/// </summary>
internal sealed class ChangeRequest : Entity
{
public Guid OrganizationId { get; private set; }
public string CustomerName { get; private set; } = null!;
public string Title { get; private set; } = null!;
public string? Description { get; private set; }
public ChangeRequestStatus Status { get; private set; }
public decimal? EstimateHours { get; private set; }
public decimal? Amount { get; private set; }
public string Currency { get; private set; } = "USD";
public DateTimeOffset CreatedAtUtc { get; private set; }
public DateTimeOffset UpdatedAtUtc { get; private set; }
public DateTimeOffset? EstimatedAtUtc { get; private set; }
public DateTimeOffset? ApprovedAtUtc { get; private set; }
public DateTimeOffset? PaidAtUtc { get; private set; }
public DateTimeOffset? LiveAtUtc { get; private set; }
private ChangeRequest()
{
}
public ChangeRequest(Guid organizationId, string customerName, string title, string? description, DateTimeOffset nowUtc)
{
OrganizationId = organizationId;
CustomerName = customerName;
Title = title;
Description = description;
Status = ChangeRequestStatus.Requested;
CreatedAtUtc = nowUtc;
UpdatedAtUtc = nowUtc;
}
/// <summary>Quote the request: lock in the total hours (summed from its steps) and an optional price.</summary>
public void Estimate(decimal totalHours, decimal? amount, string? currency, DateTimeOffset nowUtc)
{
Require(ChangeRequestStatus.Requested, ChangeRequestStatus.Estimated);
EstimateHours = totalHours;
Amount = amount;
if (!string.IsNullOrWhiteSpace(currency))
{
Currency = currency.Trim().ToUpperInvariant();
}
Status = ChangeRequestStatus.Estimated;
EstimatedAtUtc = nowUtc;
UpdatedAtUtc = nowUtc;
}
public void Approve(DateTimeOffset nowUtc)
{
Require(ChangeRequestStatus.Estimated);
Status = ChangeRequestStatus.Approved;
ApprovedAtUtc = nowUtc;
UpdatedAtUtc = nowUtc;
}
public void RecordPayment(DateTimeOffset nowUtc)
{
Require(ChangeRequestStatus.Approved);
Status = ChangeRequestStatus.Paid;
PaidAtUtc = nowUtc;
UpdatedAtUtc = nowUtc;
}
public void GoLive(DateTimeOffset nowUtc)
{
Require(ChangeRequestStatus.Paid);
Status = ChangeRequestStatus.Live;
LiveAtUtc = nowUtc;
UpdatedAtUtc = nowUtc;
}
public void Reject(DateTimeOffset nowUtc)
{
if (Status is ChangeRequestStatus.Live or ChangeRequestStatus.Rejected)
{
throw new InvalidOperationException($"A {Status} change request can't be rejected.");
}
Status = ChangeRequestStatus.Rejected;
UpdatedAtUtc = nowUtc;
}
private void Require(params ChangeRequestStatus[] allowed)
{
if (Array.IndexOf(allowed, Status) < 0)
{
throw new InvalidOperationException(
$"Change request is {Status}; this step requires {string.Join(" or ", allowed)}.");
}
}
}
@@ -0,0 +1,65 @@
using TeamUp.SharedKernel.Domain;
namespace TeamUp.Modules.OrgBoard.Domain;
internal enum ChangeStepStatus
{
Pending,
InProgress,
Done,
Blocked,
}
/// <summary>
/// One unit of cross-division work on a change request: a division's slice, its hours estimate, and an
/// optional dependency on an earlier step (e.g. Ops can't go live until Engineering's step is done).
/// The chain of <see cref="DependsOnStepId"/> links is how a request models "a lot of dependencies".
/// </summary>
internal sealed class ChangeRequestStep : Entity
{
public Guid ChangeRequestId { get; private set; }
public Guid? DivisionId { get; private set; }
public string Title { get; private set; } = null!;
public decimal EstimateHours { get; private set; }
public ChangeStepStatus Status { get; private set; }
public Guid? DependsOnStepId { get; private set; }
public int Order { get; private set; }
public DateTimeOffset CreatedAtUtc { get; private set; }
public DateTimeOffset UpdatedAtUtc { get; private set; }
private ChangeRequestStep()
{
}
public ChangeRequestStep(
Guid changeRequestId,
Guid? divisionId,
string title,
decimal estimateHours,
Guid? dependsOnStepId,
int order,
DateTimeOffset nowUtc)
{
ChangeRequestId = changeRequestId;
DivisionId = divisionId;
Title = title;
EstimateHours = estimateHours;
Status = ChangeStepStatus.Pending;
DependsOnStepId = dependsOnStepId;
Order = order;
CreatedAtUtc = nowUtc;
UpdatedAtUtc = nowUtc;
}
public void Advance(ChangeStepStatus status, DateTimeOffset nowUtc)
{
Status = status;
UpdatedAtUtc = nowUtc;
}
public void SetEstimate(decimal estimateHours, DateTimeOffset nowUtc)
{
EstimateHours = estimateHours;
UpdatedAtUtc = nowUtc;
}
}
@@ -0,0 +1,288 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.OrgBoard.Domain;
using TeamUp.Modules.OrgBoard.Persistence;
using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Auditing;
namespace TeamUp.Modules.OrgBoard.Endpoints;
/// <summary>
/// The cross-division delivery pipeline: a customer change request is logged, scoped into per-division
/// steps (with hours + dependencies), then advanced through estimate → approve → pay → go-live. Each
/// commercial transition is guarded on the aggregate, so the pipeline can only move forward in order.
/// Reads need board-view; the commercial actions are owner-level (same capability as shaping the org).
/// </summary>
internal static class ChangeRequestEndpoints
{
public static void MapTo(RouteGroupBuilder group)
{
group.MapPost("/change-requests", Create).RequireAuthorization();
group.MapGet("/change-requests", List).RequireAuthorization();
group.MapGet("/change-requests/{id:guid}", Get).RequireAuthorization();
group.MapPost("/change-requests/{id:guid}/steps", AddStep).RequireAuthorization();
group.MapPatch("/change-requests/{id:guid}/steps/{stepId:guid}", AdvanceStep).RequireAuthorization();
group.MapPost("/change-requests/{id:guid}/estimate", Estimate).RequireAuthorization();
group.MapPost("/change-requests/{id:guid}/approve", Approve).RequireAuthorization();
group.MapPost("/change-requests/{id:guid}/pay", Pay).RequireAuthorization();
group.MapPost("/change-requests/{id:guid}/go-live", GoLive).RequireAuthorization();
group.MapPost("/change-requests/{id:guid}/reject", Reject).RequireAuthorization();
}
private static async Task<IResult> Create(
CreateChangeRequestRequest 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.CustomerName) || string.IsNullOrWhiteSpace(request.Title))
{
return Results.BadRequest("Customer and title are required.");
}
var cr = new ChangeRequest(
request.OrganizationId, request.CustomerName.Trim(), request.Title.Trim(),
string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), clock.GetUtcNow());
db.ChangeRequests.Add(cr);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("change-request.created", "ChangeRequest", cr.Id, user.MemberId, cr.Title), ct);
return Results.Ok(ToSummary(cr, steps: [], doneSteps: 0));
}
private static async Task<IResult> List(
Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
{
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(organizationId)))
{
return Results.Forbid();
}
var requests = await db.ChangeRequests
.Where(c => c.OrganizationId == organizationId)
.OrderByDescending(c => c.CreatedAtUtc)
.ToListAsync(ct);
var ids = requests.Select(c => c.Id).ToList();
var steps = await db.ChangeRequestSteps.Where(s => ids.Contains(s.ChangeRequestId)).ToListAsync(ct);
var byRequest = steps.GroupBy(s => s.ChangeRequestId).ToDictionary(g => g.Key, g => g.ToList());
var summaries = requests
.Select(c =>
{
var its = byRequest.TryGetValue(c.Id, out var list) ? list : [];
return ToSummary(c, its, its.Count(s => s.Status == ChangeStepStatus.Done));
})
.ToList();
return Results.Ok(summaries);
}
private static async Task<IResult> Get(
Guid id, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
{
var cr = await db.ChangeRequests.FirstOrDefaultAsync(c => c.Id == id, ct);
if (cr is null)
{
return Results.NotFound();
}
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(cr.OrganizationId)))
{
return Results.Forbid();
}
var steps = await db.ChangeRequestSteps
.Where(s => s.ChangeRequestId == id)
.OrderBy(s => s.Order)
.ToListAsync(ct);
var divisions = await db.Divisions
.Where(d => d.OrganizationId == cr.OrganizationId)
.ToDictionaryAsync(d => d.Id, d => d.Name, ct);
return Results.Ok(ToDetail(cr, steps, divisions));
}
private static async Task<IResult> AddStep(
Guid id, AddChangeStepRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var cr = await LoadForWrite(db, permissions, id, ct);
if (cr.Error is not null)
{
return cr.Error;
}
if (string.IsNullOrWhiteSpace(request.Title))
{
return Results.BadRequest("Step title is required.");
}
if (request.DivisionId is { } divisionId
&& !await db.Divisions.AnyAsync(d => d.Id == divisionId && d.OrganizationId == cr.Request!.OrganizationId, ct))
{
return Results.BadRequest("Division not found in this organization.");
}
var order = await db.ChangeRequestSteps.CountAsync(s => s.ChangeRequestId == id, ct);
var step = new ChangeRequestStep(
id, request.DivisionId, request.Title.Trim(), request.EstimateHours,
request.DependsOnStepId, order, clock.GetUtcNow());
db.ChangeRequestSteps.Add(step);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("change-request.step-added", "ChangeRequest", id, user.MemberId, step.Title), ct);
return Results.Ok(await BuildDetail(db, cr.Request!, ct));
}
private static async Task<IResult> AdvanceStep(
Guid id, Guid stepId, AdvanceStepRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var cr = await LoadForWrite(db, permissions, id, ct);
if (cr.Error is not null)
{
return cr.Error;
}
var step = await db.ChangeRequestSteps.FirstOrDefaultAsync(s => s.Id == stepId && s.ChangeRequestId == id, ct);
if (step is null)
{
return Results.NotFound("Step not found.");
}
step.Advance(request.Status, clock.GetUtcNow());
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("change-request.step-advanced", "ChangeRequest", id, user.MemberId, request.Status.ToString()), ct);
return Results.Ok(await BuildDetail(db, cr.Request!, ct));
}
private static async Task<IResult> Estimate(
Guid id, EstimateChangeRequestRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var cr = await LoadForWrite(db, permissions, id, ct);
if (cr.Error is not null)
{
return cr.Error;
}
var steps = await db.ChangeRequestSteps.Where(s => s.ChangeRequestId == id).ToListAsync(ct);
if (steps.Count == 0)
{
return Results.BadRequest("Add at least one step before estimating.");
}
var totalHours = steps.Sum(s => s.EstimateHours);
return await Transition(
db, audit, user, cr.Request!, c => c.Estimate(totalHours, request.Amount, request.Currency, clock.GetUtcNow()),
"change-request.estimated", $"{totalHours}h", ct);
}
private static Task<IResult> Approve(
Guid id, ICurrentUser user, IPermissionService permissions, IAuditLog audit,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) =>
SimpleTransition(id, user, permissions, audit, db, c => c.Approve(clock.GetUtcNow()), "change-request.approved", ct);
private static Task<IResult> Pay(
Guid id, ICurrentUser user, IPermissionService permissions, IAuditLog audit,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) =>
SimpleTransition(id, user, permissions, audit, db, c => c.RecordPayment(clock.GetUtcNow()), "change-request.paid", ct);
private static Task<IResult> GoLive(
Guid id, ICurrentUser user, IPermissionService permissions, IAuditLog audit,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) =>
SimpleTransition(id, user, permissions, audit, db, c => c.GoLive(clock.GetUtcNow()), "change-request.live", ct);
private static Task<IResult> Reject(
Guid id, ICurrentUser user, IPermissionService permissions, IAuditLog audit,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) =>
SimpleTransition(id, user, permissions, audit, db, c => c.Reject(clock.GetUtcNow()), "change-request.rejected", ct);
private static async Task<IResult> SimpleTransition(
Guid id, ICurrentUser user, IPermissionService permissions, IAuditLog audit, OrgBoardDbContext db,
Action<ChangeRequest> apply, string auditAction, CancellationToken ct)
{
var cr = await LoadForWrite(db, permissions, id, ct);
if (cr.Error is not null)
{
return cr.Error;
}
return await Transition(db, audit, user, cr.Request!, apply, auditAction, cr.Request!.Status.ToString(), ct);
}
// Apply a guarded pipeline transition, turning the domain's guard violation into a 400.
private static async Task<IResult> Transition(
OrgBoardDbContext db, IAuditLog audit, ICurrentUser user, ChangeRequest cr,
Action<ChangeRequest> apply, string auditAction, string detail, CancellationToken ct)
{
try
{
apply(cr);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(ex.Message);
}
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent(auditAction, "ChangeRequest", cr.Id, user.MemberId, detail), ct);
return Results.Ok(await BuildDetail(db, cr, ct));
}
private static async Task<(ChangeRequest? Request, IResult? Error)> LoadForWrite(
OrgBoardDbContext db, IPermissionService permissions, Guid id, CancellationToken ct)
{
var cr = await db.ChangeRequests.FirstOrDefaultAsync(c => c.Id == id, ct);
if (cr is null)
{
return (null, Results.NotFound());
}
if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(cr.OrganizationId)))
{
return (null, Results.Forbid());
}
return (cr, null);
}
private static async Task<ChangeRequestDetail> BuildDetail(OrgBoardDbContext db, ChangeRequest cr, CancellationToken ct)
{
var steps = await db.ChangeRequestSteps
.Where(s => s.ChangeRequestId == cr.Id)
.OrderBy(s => s.Order)
.ToListAsync(ct);
var divisions = await db.Divisions
.Where(d => d.OrganizationId == cr.OrganizationId)
.ToDictionaryAsync(d => d.Id, d => d.Name, ct);
return ToDetail(cr, steps, divisions);
}
private static ChangeRequestSummary ToSummary(ChangeRequest cr, IReadOnlyList<ChangeRequestStep> steps, int doneSteps) =>
new(cr.Id, cr.OrganizationId, cr.CustomerName, cr.Title, cr.Status.ToString(),
cr.EstimateHours, cr.Amount, cr.Currency, steps.Count, doneSteps);
private static ChangeRequestDetail ToDetail(
ChangeRequest cr, IReadOnlyList<ChangeRequestStep> steps, Dictionary<Guid, string> divisions) =>
new(
ToSummary(cr, steps, steps.Count(s => s.Status == ChangeStepStatus.Done)),
cr.Description,
steps.Sum(s => s.EstimateHours),
steps.Select(s => new ChangeStepResponse(
s.Id,
s.DivisionId,
s.DivisionId is { } d && divisions.TryGetValue(d, out var name) ? name : null,
s.Title,
s.EstimateHours,
s.Status.ToString(),
s.DependsOnStepId,
s.Order)).ToList(),
cr.CreatedAtUtc,
cr.EstimatedAtUtc,
cr.ApprovedAtUtc,
cr.PaidAtUtc,
cr.LiveAtUtc);
}
@@ -79,6 +79,49 @@ internal sealed record AgentResponse(
List<string> Docs,
string? Persona);
// --- Cross-division delivery pipeline: customer change requests ---
internal sealed record CreateChangeRequestRequest(Guid OrganizationId, string CustomerName, string Title, string? Description);
internal sealed record AddChangeStepRequest(string Title, decimal EstimateHours, Guid? DivisionId = null, Guid? DependsOnStepId = null);
internal sealed record EstimateChangeRequestRequest(decimal? Amount = null, string? Currency = null);
internal sealed record AdvanceStepRequest(ChangeStepStatus Status);
internal sealed record ChangeStepResponse(
Guid Id,
Guid? DivisionId,
string? DivisionName,
string Title,
decimal EstimateHours,
string Status,
Guid? DependsOnStepId,
int Order);
internal sealed record ChangeRequestSummary(
Guid Id,
Guid OrganizationId,
string CustomerName,
string Title,
string Status,
decimal? EstimateHours,
decimal? Amount,
string Currency,
int StepCount,
int DoneStepCount);
internal sealed record ChangeRequestDetail(
ChangeRequestSummary Summary,
string? Description,
decimal TotalStepHours,
IReadOnlyList<ChangeStepResponse> Steps,
DateTimeOffset CreatedAtUtc,
DateTimeOffset? EstimatedAtUtc,
DateTimeOffset? ApprovedAtUtc,
DateTimeOffset? PaidAtUtc,
DateTimeOffset? LiveAtUtc);
// --- Agent profiles (AGENTS.md): a per-org library of reusable agent definitions ---
internal sealed record UploadAgentProfileRequest(Guid OrganizationId, string Content);
@@ -49,6 +49,7 @@ internal static class OrgBoardEndpoints
AgentProfileEndpoints.MapTo(group);
ProductProfileEndpoints.MapTo(group);
ChangeRequestEndpoints.MapTo(group);
}
private static TaskResponse ToResponse(WorkItem item) => new(
@@ -0,0 +1,598 @@
// <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("20260617041239_AddChangeRequests")]
partial class AddChangeRequests
{
/// <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.PrimitiveCollection<List<Guid>>("McpServerIds")
.IsRequired()
.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<string>("Persona")
.HasColumnType("text");
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.AgentProfile", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AuthoredByMemberId")
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Monogram")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid?>("OrganizationId")
.HasColumnType("uuid");
b.Property<int>("Origin")
.HasColumnType("integer");
b.Property<string>("ProfileKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("RecommendedAutonomy")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.PrimitiveCollection<List<string>>("Roles")
.IsRequired()
.HasColumnType("text[]");
b.PrimitiveCollection<List<string>>("SkillKeys")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Summary")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Visibility")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("OrganizationId", "ProfileKey", "Version")
.IsUnique();
NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("OrganizationId", "ProfileKey", "Version"), false);
b.ToTable("agent_profiles", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.ChangeRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal?>("Amount")
.HasPrecision(12, 2)
.HasColumnType("numeric(12,2)");
b.Property<DateTimeOffset?>("ApprovedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("CustomerName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<decimal?>("EstimateHours")
.HasPrecision(9, 2)
.HasColumnType("numeric(9,2)");
b.Property<DateTimeOffset?>("EstimatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LiveAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("PaidAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.ToTable("change_requests", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.ChangeRequestStep", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChangeRequestId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DependsOnStepId")
.HasColumnType("uuid");
b.Property<Guid?>("DivisionId")
.HasColumnType("uuid");
b.Property<decimal>("EstimateHours")
.HasPrecision(9, 2)
.HasColumnType("numeric(9,2)");
b.Property<int>("Order")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ChangeRequestId");
b.ToTable("change_request_steps", "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>("Identity")
.HasColumnType("text");
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.ProductProfile", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AuthoredByMemberId")
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
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<string>("Origin")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ProfileKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Summary")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Visibility")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("OrganizationId", "ProfileKey", "Version")
.IsUnique();
NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("OrganizationId", "ProfileKey", "Version"), false);
b.ToTable("product_profiles", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AgentId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("MemberId")
.HasColumnType("uuid");
b.Property<string>("RoleName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TeamId");
b.ToTable("seats", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid?>("ProductId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("ProductId");
b.ToTable("teams", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssigneeId")
.HasColumnType("uuid");
b.Property<string>("AssigneeKind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedByMemberId")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("TeamId");
b.HasIndex("AssigneeKind", "AssigneeId");
b.ToTable("work_items", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItemTransition", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("ActorMemberId")
.HasColumnType("uuid");
b.Property<string>("FromStatus")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("OccurredAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.Property<string>("ToStatus")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("WorkItemId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TeamId");
b.HasIndex("WorkItemId");
b.ToTable("work_item_transitions", "orgboard");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,86 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddChangeRequests : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "change_request_steps",
schema: "orgboard",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChangeRequestId = table.Column<Guid>(type: "uuid", nullable: false),
DivisionId = table.Column<Guid>(type: "uuid", nullable: true),
Title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
EstimateHours = table.Column<decimal>(type: "numeric(9,2)", precision: 9, scale: 2, nullable: false),
Status = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
DependsOnStepId = table.Column<Guid>(type: "uuid", nullable: true),
Order = table.Column<int>(type: "integer", nullable: false),
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_change_request_steps", x => x.Id);
});
migrationBuilder.CreateTable(
name: "change_requests",
schema: "orgboard",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false),
CustomerName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
Description = table.Column<string>(type: "text", nullable: true),
Status = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
EstimateHours = table.Column<decimal>(type: "numeric(9,2)", precision: 9, scale: 2, nullable: true),
Amount = table.Column<decimal>(type: "numeric(12,2)", precision: 12, scale: 2, nullable: true),
Currency = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: false),
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
EstimatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ApprovedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
PaidAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LiveAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_change_requests", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_change_request_steps_ChangeRequestId",
schema: "orgboard",
table: "change_request_steps",
column: "ChangeRequestId");
migrationBuilder.CreateIndex(
name: "IX_change_requests_OrganizationId",
schema: "orgboard",
table: "change_requests",
column: "OrganizationId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "change_request_steps",
schema: "orgboard");
migrationBuilder.DropTable(
name: "change_requests",
schema: "orgboard");
}
}
}
@@ -170,6 +170,116 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
b.ToTable("agent_profiles", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.ChangeRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal?>("Amount")
.HasPrecision(12, 2)
.HasColumnType("numeric(12,2)");
b.Property<DateTimeOffset?>("ApprovedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("CustomerName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<decimal?>("EstimateHours")
.HasPrecision(9, 2)
.HasColumnType("numeric(9,2)");
b.Property<DateTimeOffset?>("EstimatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LiveAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("PaidAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.ToTable("change_requests", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.ChangeRequestStep", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChangeRequestId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DependsOnStepId")
.HasColumnType("uuid");
b.Property<Guid?>("DivisionId")
.HasColumnType("uuid");
b.Property<decimal>("EstimateHours")
.HasPrecision(9, 2)
.HasColumnType("numeric(9,2)");
b.Property<int>("Order")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ChangeRequestId");
b.ToTable("change_request_steps", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Division", b =>
{
b.Property<Guid>("Id")
@@ -17,6 +17,8 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
public DbSet<ProductProfile> ProductProfiles => Set<ProductProfile>();
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
public DbSet<WorkItemTransition> Transitions => Set<WorkItemTransition>();
public DbSet<ChangeRequest> ChangeRequests => Set<ChangeRequest>();
public DbSet<ChangeRequestStep> ChangeRequestSteps => Set<ChangeRequestStep>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -133,5 +135,28 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
transition.HasIndex(t => t.WorkItemId);
transition.HasIndex(t => t.TeamId);
});
modelBuilder.Entity<ChangeRequest>(cr =>
{
cr.ToTable("change_requests");
cr.HasKey(c => c.Id);
cr.Property(c => c.CustomerName).HasMaxLength(200).IsRequired();
cr.Property(c => c.Title).HasMaxLength(300).IsRequired();
cr.Property(c => c.Status).HasConversion<string>().HasMaxLength(16);
cr.Property(c => c.EstimateHours).HasPrecision(9, 2);
cr.Property(c => c.Amount).HasPrecision(12, 2);
cr.Property(c => c.Currency).HasMaxLength(8).IsRequired();
cr.HasIndex(c => c.OrganizationId);
});
modelBuilder.Entity<ChangeRequestStep>(step =>
{
step.ToTable("change_request_steps");
step.HasKey(s => s.Id);
step.Property(s => s.Title).HasMaxLength(300).IsRequired();
step.Property(s => s.Status).HasConversion<string>().HasMaxLength(16);
step.Property(s => s.EstimateHours).HasPrecision(9, 2);
step.HasIndex(s => s.ChangeRequestId);
});
}
}