Compare commits

..

13 Commits

Author SHA1 Message Date
soroush.asadi d6a0638c59 Harden API client against stale-backend HTML fallback
When the running backend is missing a route, the SPA fallback serves index.html
with a 200, and the api helper returned undefined — which crashed pages on
.map/.length (the Delivery pipeline white-screened against an old build). Now a
non-JSON 2xx on an /api call throws a clear 'API is running an older build'
error instead, and PipelinePage defensively coerces its lists to arrays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:33:59 +03:30
soroush.asadi c12935ad74 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>
2026-06-17 07:47:57 +03:30
soroush.asadi 5c2b697b66 Theme 3: autonomous Run all — fan a big task's children across the AI seats
New POST /api/orgboard/tasks/{id}/run-all dispatches a run for every
outstanding child of a task (not done, no artifact yet), round-robined across
the team's configured AI seats, or all to one seat if specified. Agents still
act per their own autonomy, so gated work lands in review. The board task
drawer gets a Run all with AI button on any task that has children, so one
click puts the whole breakdown to work — then watch progress on the Delivery
dashboard and download the result.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 07:36:59 +03:30
soroush.asadi 0658061580 Theme 3: download a product as a project (zip export)
New GET /api/orgboard/products/{id}/export streams a zip of the product's
delivered work: PRODUCT.md (identity), each team's artifacts written as real
source files when the artifact is a single fenced code block (App.tsx,
schema.sql, …) or markdown otherwise, plus a README manifest. Gated on
board-view permission. The Delivery dashboard gets a Download project button
that fetches the file with the auth header and saves it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 07:30:37 +03:30
soroush.asadi 1e33d57b4e Live run progress: watch the agent work in real time
Clicking Run on a task now keeps the drawer open and streams the agent's
progress instead of just closing. A new RunProgress panel polls the run
(GET /api/assembler/runs/{id}) and shows a live timeline — Queued -> Thinking
-> Delivered — with elapsed time, any MCP tool calls, the action produced and
its risk tag, and an expandable output. When the run settles it refreshes the
board and pulses the review badge. The panel resets when the drawer switches
tasks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:42:36 +03:30
soroush.asadi 861efa4e20 Delivery dashboard: per-product progress, remaining work, and quality stats
Theme 1 of the roadmap. New /delivery page shows, per product: % complete
with a progress bar, a Backlog/InProgress/InReview/Done column breakdown,
quality stats from governance analytics (approval rate, avg edit distance,
awaiting review), per-team progress bars, and the remaining-task list.
Selected product persists in localStorage. Wired into routing and the
Insights sidebar group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:38:08 +03:30
soroush.asadi e26f304675 Run it in TeamUp: live React preview of a task's artifact
Adds LivePreview — a sandboxed iframe that transpiles an agent's component with Babel and
renders it live with React + Tailwind from CDN (no build step), so a generated page runs
inside TeamUp. A "Preview" button on any task with an artifact opens a full-screen live
view. Pairs with steering the engineer agent to output a single self-contained App component.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 23:35:28 +03:30
soroush.asadi 63fef8799d Tasks visibly progress: Run → In Progress (+ close drawer); approve → In Review
A task assigned to an agent never moved, so it looked like nothing happened. Now:
- Clicking Run moves the task from Backlog to In Progress and closes the drawer.
- When an approved artifact lands on a task, it moves to In Review (unless already Done),
  so the delivered result is visible on the board instead of sitting in Backlog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:38:34 +03:30
soroush.asadi 1f562fd633 Fix: child tasks only from spec/breakdown agents; live review badge
Two fixes from real usage:
- Child-task creation is now gated to story-producing skills (spec-writing,
  story-breakdown). A code/design/test agent's output is the artifact — a numbered list
  in it (e.g. file names from an engineer) is no longer mistaken for child stories.
- The review-inbox badge now updates without a refresh: it polls more often (6s),
  refetches on window focus, and reacts to a REVIEWS_CHANGED event the board fires after
  Run (with a couple of delayed pulses to catch the ~5s completion) and the review page
  fires after approve / send back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:22:57 +03:30
soroush.asadi 9993ebb2b4 Delete tasks from the board
Adds DELETE /api/orgboard/tasks/{id} (WorkTasks permission) and a "Delete task" button
in the task drawer (with confirm). Children are detached (kept as top-level) rather than
deleted; status-transition history is dropped. There was previously no way to remove a task.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:03:14 +03:30
soroush.asadi cb9ce34309 Defensive: prevent duplicate agent runs and stacked review items
Two server-side guards so repeated "Run" clicks can't pile up duplicates:
- Dispatcher: returns the in-flight run if one is already Queued/Running for the same
  (seat, task) instead of dispatching another model call.
- Action gate: if an identical action is already Pending for the same (task, agent),
  it keeps that review item rather than stacking another copy in the inbox.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 21:47:04 +03:30
soroush.asadi b398e68c8b Sidebar: pending-review count badge on the Review inbox item
After dispatching an agent run, its proposal goes to the review inbox, but the board
gave no signal — users thought "nothing happened". The Review inbox nav item now shows
an amber count of held actions awaiting review (polled), so waiting work is always visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 21:35:09 +03:30
soroush.asadi 9c18ddfc8f Fix: board remembers the selected team across refresh
The board's selected team was local state that defaulted to the first team on every load,
so a refresh snapped away from the team you were on (and its tasks appeared to vanish).
Now the selection is restored from localStorage, validated against the current teams, and
persisted on change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 21:15:23 +03:30
24 changed files with 2812 additions and 16 deletions
+4
View File
@@ -4,12 +4,14 @@ import { AgentProfilesPage } from '@/pages/AgentProfilesPage'
import { AnalyticsPage } from '@/pages/AnalyticsPage'
import { BoardPage } from '@/pages/BoardPage'
import { CartablePage } from '@/pages/CartablePage'
import { DeliveryPage } from '@/pages/DeliveryPage'
import { GetStartedPage } from '@/pages/GetStartedPage'
import { KnowledgeBasePage } from '@/pages/KnowledgeBasePage'
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'
@@ -31,6 +33,8 @@ export default function App() {
<Route path="/seats" element={token ? <SeatsPage /> : <Navigate to="/login" replace />} />
<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 />} />
+53 -2
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from 'react'
import { useEffect, useState, type ReactNode } from 'react'
import { Link, useLocation } from 'react-router'
import {
BookMarked,
@@ -17,16 +17,58 @@ import {
Rocket,
ShieldCheck,
Sparkles,
TrendingUp,
Users,
Workflow,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { api } from '@/lib/api'
import { cn } from '@/lib/utils'
import { useAuth } from '@/store/auth'
/** Event other pages fire after dispatching a run or deciding a review, so the badge updates at once. */
export const REVIEWS_CHANGED = 'teamup:reviews-changed'
/** Tracks the count of held actions awaiting review: polls, and refetches on focus / a change event. */
function usePendingReviewCount(organizationId: string | null): number {
const [count, setCount] = useState(0)
useEffect(() => {
if (!organizationId) {
setCount(0)
return
}
let cancelled = false
const tick = async () => {
try {
const items = await api.get<unknown[]>(`/api/governance/reviews?organizationId=${organizationId}&status=Pending`)
if (!cancelled) setCount(items.length)
} catch {
// leave the last known count on a transient failure
}
}
void tick()
const id = setInterval(tick, 6000)
const refetch = () => void tick()
window.addEventListener(REVIEWS_CHANGED, refetch)
window.addEventListener('focus', refetch)
document.addEventListener('visibilitychange', refetch)
return () => {
cancelled = true
clearInterval(id)
window.removeEventListener(REVIEWS_CHANGED, refetch)
window.removeEventListener('focus', refetch)
document.removeEventListener('visibilitychange', refetch)
}
}, [organizationId])
return count
}
export function AppShell({ children }: { children: ReactNode }) {
const email = useAuth((s) => s.email)
const logout = useAuth((s) => s.logout)
const organizationId = useAuth((s) => s.organizationId)
const reviewCount = usePendingReviewCount(organizationId)
return (
<div className="flex min-h-screen text-foreground">
@@ -59,7 +101,8 @@ 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={ShieldCheck} label="Review inbox" to="/reviews" 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" />
<NavItem icon={Boxes} label="Structure" to="/structure" color="#34d399" />
@@ -73,6 +116,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<NavItem icon={Package} label="Product profiles" to="/product-profiles" color="#a78bfa" />
<NavSection label="Insights" />
<NavItem icon={TrendingUp} label="Delivery" to="/delivery" color="#2dd4bf" />
<NavItem icon={Gauge} label="Performance" to="/performance" color="#2dd4bf" />
<NavItem icon={ChartColumn} label="Analytics" to="/analytics" color="#2dd4bf" />
@@ -115,12 +159,14 @@ function NavItem({
to,
muted,
color,
badge,
}: {
icon: LucideIcon
label: string
to?: string
muted?: boolean
color?: string
badge?: number
}) {
const location = useLocation()
const active = to ? location.pathname === to : false
@@ -142,6 +188,11 @@ function NavItem({
<Icon className="size-3.5" />
</span>
{label}
{!!badge && badge > 0 && (
<span className="ml-auto grid h-5 min-w-5 place-items-center rounded-full bg-amber-400 px-1.5 text-[11px] font-semibold text-amber-950">
{badge}
</span>
)}
{muted && <span className="ml-auto text-[10px] uppercase tracking-wide opacity-70">soon</span>}
</>
)
+50
View File
@@ -0,0 +1,50 @@
import { useMemo } from 'react'
/**
* Pull the most likely runnable code out of an artifact: the largest fenced code block if the agent
* wrapped its answer in markdown, otherwise the whole text.
*/
export function extractCode(artifact: string): string {
const blocks = [...artifact.matchAll(/```(?:tsx|jsx|ts|js|javascript|typescript)?\s*\n([\s\S]*?)```/g)].map((m) => m[1])
if (blocks.length === 0) return artifact.trim()
return blocks.sort((a, b) => b.length - a.length)[0].trim()
}
/** Builds a self-contained HTML document that transpiles the component (Babel) and renders it with
* React + Tailwind from CDN. The agent's code must define a component named `App`. */
function harness(code: string): string {
const json = JSON.stringify(code)
return `<!doctype html><html><head><meta charset="utf-8"/>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/@babel/standalone@7/babel.min.js"></script>
<script type="importmap">{"imports":{"react":"https://esm.sh/react@18.3.1","react-dom":"https://esm.sh/react-dom@18.3.1","react-dom/client":"https://esm.sh/react-dom@18.3.1/client","react/jsx-runtime":"https://esm.sh/react@18.3.1/jsx-runtime","react/jsx-dev-runtime":"https://esm.sh/react@18.3.1/jsx-dev-runtime"}}</script>
<style>body{margin:0;font-family:system-ui,sans-serif}.__err{padding:18px;font:12px/1.6 ui-monospace,monospace;color:#b91c1c;white-space:pre-wrap}</style>
</head><body>
<div id="root"></div>
<script>
var CODE = ${json};
function esc(s){return String(s).replace(/[<&]/g,function(c){return c==='<'?'&lt;':'&amp;'})}
function showErr(m){var r=document.getElementById('root');if(r)r.innerHTML='<div class="__err">Preview error:\\n'+esc(m)+'</div>'}
window.addEventListener('error',function(e){showErr(e.message||e)});
window.addEventListener('unhandledrejection',function(e){showErr((e.reason&&e.reason.message)||e.reason||'Error')});
try{
var t = Babel.transform(CODE,{presets:[['react',{runtime:'automatic'}],'typescript'],filename:'App.tsx'}).code;
var boot = t + "\\nimport React from 'react';\\nimport { createRoot } from 'react-dom/client';\\nvar __C = (typeof App!=='undefined') ? App : null;\\nif(!__C){ throw new Error('No component named App. Output a component, e.g. export default function App(){ return <div/> }'); }\\ncreateRoot(document.getElementById('root')).render(React.createElement(__C));";
var s=document.createElement('script');s.type='module';s.textContent=boot;document.body.appendChild(s);
}catch(e){showErr(e.message)}
</script>
</body></html>`
}
/** Runs an agent's React component artifact live, in a sandboxed iframe. */
export function LivePreview({ artifact, className }: { artifact: string; className?: string }) {
const srcDoc = useMemo(() => harness(extractCode(artifact)), [artifact])
return (
<iframe
title="Live preview"
srcDoc={srcDoc}
sandbox="allow-scripts allow-same-origin"
className={className ?? 'h-[70vh] w-full rounded-lg border bg-white'}
/>
)
}
+196
View File
@@ -0,0 +1,196 @@
import { useEffect, useRef, useState } from 'react'
import { AlertTriangle, BrainCircuit, Check, Inbox, Loader2, Wrench } from 'lucide-react'
import { api } from '@/lib/api'
/** The live run record as returned by GET /api/assembler/runs/{id}. */
interface Run {
id: string
status: 'Queued' | 'Running' | 'Completed' | 'Failed' | string
actionType: string | null
actionRisk: string | null
output: string | null
error: string | null
resultJson: string | null
latencyMs: number | null
createdAtUtc: string
completedAtUtc: string | null
}
interface ToolCall {
name?: string
tool?: string
server?: string
ok?: boolean
}
const TERMINAL = new Set(['Completed', 'Failed'])
/** Pulls the tool-call list out of the run's result JSON, if the agent used any MCP tools. */
function readToolCalls(run: Run | null): ToolCall[] {
if (!run?.resultJson) return []
try {
const parsed = JSON.parse(run.resultJson) as { toolCalls?: ToolCall[] }
return Array.isArray(parsed.toolCalls) ? parsed.toolCalls : []
} catch {
return []
}
}
/**
* Watches one agent run live: polls the run until it reaches a terminal state and renders a
* step-by-step timeline (Queued → Thinking → Done) with elapsed time, the action the agent took,
* its risk tag, any tool calls, and where the result landed.
*/
export function RunProgress({
runId,
onSettled,
}: {
runId: string
/** Fired once when the run reaches Completed/Failed — lets the parent refresh the board + badge. */
onSettled?: (run: Run) => void
}) {
const [run, setRun] = useState<Run | null>(null)
const [elapsed, setElapsed] = useState(0)
const settledRef = useRef(false)
const onSettledRef = useRef(onSettled)
onSettledRef.current = onSettled
useEffect(() => {
let active = true
let pollTimer = 0
const start = Date.now()
const tick = window.setInterval(() => {
if (active && !settledRef.current) setElapsed(Math.round((Date.now() - start) / 1000))
}, 1000)
const poll = async () => {
try {
const r = await api.get<Run>(`/api/assembler/runs/${runId}`)
if (!active) return
setRun(r)
if (TERMINAL.has(r.status)) {
settledRef.current = true
onSettledRef.current?.(r)
return
}
} catch {
/* transient — keep polling */
}
if (active) pollTimer = window.setTimeout(poll, 1200)
}
void poll()
return () => {
active = false
window.clearTimeout(pollTimer)
window.clearInterval(tick)
}
}, [runId])
const status = run?.status ?? 'Queued'
const failed = status === 'Failed'
const done = status === 'Completed'
const running = status === 'Running'
const tools = readToolCalls(run)
const seconds = run?.latencyMs != null ? (run.latencyMs / 1000).toFixed(1) : elapsed.toString()
return (
<div className="flex flex-col gap-3 rounded-xl border bg-card/60 p-4 backdrop-blur-sm">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">Agent at work</span>
<span className="text-xs tabular-nums text-muted-foreground">{seconds}s</span>
</div>
<ol className="flex flex-col gap-2.5">
<Step
state={status === 'Queued' ? 'active' : 'done'}
icon={Inbox}
title="Queued"
detail="Task accepted — waiting for a worker."
/>
<Step
state={failed && !done ? (run?.output ? 'done' : 'error') : running ? 'active' : status === 'Queued' ? 'pending' : 'done'}
icon={BrainCircuit}
title="Thinking"
detail="Assembling the prompt and calling the model."
/>
{tools.length > 0 && (
<Step
state="done"
icon={Wrench}
title={`Used ${tools.length} tool${tools.length === 1 ? '' : 's'}`}
detail={tools.map((t) => t.name ?? t.tool).filter(Boolean).join(', ')}
/>
)}
<Step
state={failed ? 'error' : done ? 'done' : 'pending'}
icon={done ? Check : AlertTriangle}
title={failed ? 'Failed' : 'Delivered'}
detail={
failed
? run?.error ?? 'The run failed.'
: done
? `${run?.actionType ?? 'Result'} produced — sent to the review inbox.`
: 'Result will land in the review inbox.'
}
/>
</ol>
{done && run?.actionType && (
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
<span className="rounded-md bg-muted px-2 py-0.5 text-xs font-medium">{run.actionType}</span>
{run.actionRisk && (
<span className="rounded-md bg-amber-500/15 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-400">
{run.actionRisk} risk
</span>
)}
</div>
)}
{done && run?.output && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">Show output</summary>
<div className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-lg bg-muted p-3 leading-relaxed">
{run.output}
</div>
</details>
)}
</div>
)
}
type StepState = 'pending' | 'active' | 'done' | 'error'
function Step({
state,
icon: Icon,
title,
detail,
}: {
state: StepState
icon: typeof Check
title: string
detail?: string
}) {
const ring =
state === 'done'
? 'border-approved bg-approved/15 text-approved'
: state === 'active'
? 'border-primary bg-primary/15 text-primary'
: state === 'error'
? 'border-destructive bg-destructive/15 text-destructive'
: 'border-border bg-muted/40 text-muted-foreground'
return (
<li className="flex items-start gap-3">
<span className={`mt-0.5 grid size-7 shrink-0 place-items-center rounded-full border ${ring}`}>
{state === 'active' ? <Loader2 className="size-3.5 animate-spin" /> : <Icon className="size-3.5" />}
</span>
<div className="min-w-0">
<p className={`text-sm font-medium ${state === 'pending' ? 'text-muted-foreground' : ''}`}>{title}</p>
{detail && <p className="text-xs text-muted-foreground">{detail}</p>}
</div>
</li>
)
}
+12 -1
View File
@@ -21,7 +21,18 @@ async function request<T>(method: string, url: string, body?: unknown): Promise<
}
const contentType = response.headers.get('content-type') ?? ''
return contentType.includes('application/json') ? ((await response.json()) as T) : (undefined as T)
if (contentType.includes('application/json')) {
return (await response.json()) as T
}
// A non-JSON 2xx on an /api call is almost always the SPA fallback (index.html) — i.e. the route
// doesn't exist on the backend that's running (a stale build). Fail loudly so callers surface it,
// instead of returning undefined and letting the page crash on `.map`/`.length`.
if (url.startsWith('/api') && contentType.includes('text/html')) {
throw new Error(`Unexpected HTML from ${url}. The API is likely running an older build — restart the server.`)
}
return undefined as T
}
export const api = {
+108 -11
View File
@@ -8,9 +8,11 @@ import {
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import { Bot, Plus } from 'lucide-react'
import { Bot, Play, Plus, Sparkles, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { AppShell, REVIEWS_CHANGED } from '@/components/AppShell'
import { LivePreview } from '@/components/LivePreview'
import { RunProgress } from '@/components/RunProgress'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
@@ -73,7 +75,8 @@ export function BoardPage() {
const [orgName, setOrgName] = useState('')
const [teams, setTeams] = useState<Team[]>([])
const [teamId, setTeamId] = useState<string | null>(null)
// Remember the selected team across refreshes (it used to snap back to the first team).
const [teamId, setTeamId] = useState<string | null>(() => localStorage.getItem('teamup.board.team'))
const [board, setBoard] = useState<Board | null>(null)
const [newTeam, setNewTeam] = useState('')
const [newTask, setNewTask] = useState('')
@@ -90,7 +93,10 @@ export function BoardPage() {
try {
const result = await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`)
setTeams(result)
setTeamId((current) => current ?? result[0]?.id ?? null)
// Keep the current/remembered team if it still exists; otherwise fall back to the first.
setTeamId((current) =>
current && result.some((team) => team.id === current) ? current : result[0]?.id ?? null,
)
} catch (err) {
toast.error((err as Error).message)
}
@@ -112,6 +118,11 @@ export function BoardPage() {
if (teamId) void loadBoard(teamId)
}, [teamId, loadBoard])
// Persist the selected team so a refresh stays on it.
useEffect(() => {
if (teamId) localStorage.setItem('teamup.board.team', teamId)
}, [teamId])
async function run(action: () => Promise<unknown>) {
try {
await action()
@@ -396,8 +407,16 @@ function TaskDrawer({
}) {
const [busy, setBusy] = useState(false)
const [seatId, setSeatId] = useState<string>('')
const [preview, setPreview] = useState(false)
const [runId, setRunId] = useState<string | null>(null)
const aiSeats = seats.filter((s) => s.state === 'Ai')
// Switching to a different task clears the live run panel so it never shows a stale run.
const taskId = task?.id
useEffect(() => {
setRunId(null)
}, [taskId])
if (!task) {
return null
}
@@ -502,17 +521,38 @@ function TaskDrawer({
</Select>
<Button
disabled={busy || !seatId}
onClick={() =>
act(
() => api.post('/api/assembler/runs', { seatId, workItemId: task.id }),
'Dispatched — the proposal will land in the review inbox.',
)
}
onClick={() => {
let started: string | null = null
act(async () => {
const run = await api.post<{ id: string }>('/api/assembler/runs', { seatId, workItemId: task.id })
started = run?.id ?? null
// Visible feedback: the task leaves Backlog for In Progress while the agent works.
if (task.status === 'Backlog') {
await api.patch(`/api/orgboard/tasks/${task.id}/move`, { status: 'InProgress' })
}
}, 'Agent started — watch it work below.').then(() => {
if (started) setRunId(started)
window.dispatchEvent(new Event(REVIEWS_CHANGED))
})
}}
>
<Bot data-icon="inline-start" />
Run
</Button>
</div>
{/* Live process: watch the agent move through Queued → Thinking → Delivered in real time. */}
{runId && (
<RunProgress
key={runId}
runId={runId}
onSettled={() => {
// Result is in the review inbox and the board has moved — refresh both.
window.dispatchEvent(new Event(REVIEWS_CHANGED))
onChanged()
}}
/>
)}
</div>
)}
@@ -529,7 +569,30 @@ function TaskDrawer({
{children.length > 0 && (
<div className="flex flex-col gap-2">
<Label>Child tasks</Label>
<div className="flex items-center justify-between">
<Label>Child tasks</Label>
{aiSeats.length > 0 && (
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() =>
act(async () => {
const res = await api.post<{ dispatched: number }>(
`/api/orgboard/tasks/${task.id}/run-all`,
seatId ? { seatId } : {},
)
if (res.dispatched === 0) throw new Error('Nothing left to run — all children are done or delivered.')
}, 'Autopilot started — running every outstanding child on the AI seats.').then(() => {
window.dispatchEvent(new Event(REVIEWS_CHANGED))
setTimeout(() => window.dispatchEvent(new Event(REVIEWS_CHANGED)), 5000)
})
}
>
<Sparkles data-icon="inline-start" /> Run all with AI
</Button>
)}
</div>
<div className="flex flex-col gap-1.5">
{children.map((child) => (
<button
@@ -545,7 +608,41 @@ function TaskDrawer({
</div>
</div>
)}
<div className="mt-2 flex items-center gap-2 border-t pt-4">
{task.description && (
<Button variant="outline" size="sm" onClick={() => setPreview(true)}>
<Play data-icon="inline-start" /> Preview
</Button>
)}
<Button
variant="destructive"
size="sm"
className="ml-auto"
disabled={busy}
onClick={() => {
if (window.confirm(`Delete "${task.title}"? This can't be undone.`)) {
act(() => api.del(`/api/orgboard/tasks/${task.id}`), 'Task deleted.').then(onClose)
}
}}
>
<Trash2 data-icon="inline-start" /> Delete task
</Button>
</div>
</SheetContent>
{preview && task.description && (
<div className="fixed inset-0 z-[100] flex flex-col gap-2 bg-background/95 p-4 backdrop-blur-sm">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Live preview · {task.title}</span>
<Button size="sm" variant="outline" onClick={() => setPreview(false)}>Close</Button>
</div>
<LivePreview artifact={task.description} className="w-full flex-1 rounded-lg border bg-white" />
<p className="text-center text-xs text-muted-foreground">
Runs the artifact as a React component (Babel + Tailwind, no build). Define a component named App.
</p>
</div>
)}
</Sheet>
)
}
+264
View File
@@ -0,0 +1,264 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Download, Gauge } from 'lucide-react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { api } from '@/lib/api'
import { useAuth } from '@/store/auth'
interface Product {
id: string
name: string
}
interface Team {
id: string
name: string
productId: string | null
}
interface Task {
id: string
title: string
status: string
type: string
}
interface Board {
columns: { status: string; items: Task[] }[]
}
interface Analytics {
approvalRate: number | null
avgEditDistance: number | null
tasksDone: number
pendingReviews: number
}
const COLUMNS = ['Backlog', 'InProgress', 'InReview', 'Done'] as const
const LABEL: Record<string, string> = { Backlog: 'Backlog', InProgress: 'In progress', InReview: 'In review', Done: 'Done' }
interface TeamProgress {
team: Team
counts: Record<string, number>
total: number
done: number
remaining: Task[]
}
export function DeliveryPage() {
const organizationId = useAuth((s) => s.organizationId)
const [products, setProducts] = useState<Product[]>([])
const [productId, setProductId] = useState<string | null>(() => localStorage.getItem('teamup.delivery.product'))
const [teams, setTeams] = useState<TeamProgress[]>([])
const [analytics, setAnalytics] = useState<Analytics | null>(null)
const [downloading, setDownloading] = useState(false)
useEffect(() => {
if (!organizationId) return
void (async () => {
try {
const [list, an] = await Promise.all([
api.get<Product[]>(`/api/orgboard/products?organizationId=${organizationId}`),
api.get<Analytics>(`/api/governance/analytics?organizationId=${organizationId}`).catch(() => null),
])
setProducts(list)
setAnalytics(an)
setProductId((cur) => (cur && list.some((p) => p.id === cur) ? cur : list[0]?.id ?? null))
} catch (err) {
toast.error((err as Error).message)
}
})()
}, [organizationId])
const loadProduct = useCallback(async (pid: string) => {
try {
const allTeams = await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`)
const productTeams = allTeams.filter((t) => t.productId === pid)
const progress = await Promise.all(
productTeams.map(async (team) => {
const board = await api.get<Board>(`/api/orgboard/board?teamId=${team.id}`).catch(() => ({ columns: [] }))
const counts: Record<string, number> = {}
let total = 0
const remaining: Task[] = []
for (const col of board.columns) {
counts[col.status] = col.items.length
total += col.items.length
if (col.status !== 'Done') remaining.push(...col.items)
}
return { team, counts, total, done: counts.Done ?? 0, remaining }
}),
)
setTeams(progress)
} catch (err) {
toast.error((err as Error).message)
}
}, [organizationId])
useEffect(() => {
if (productId) {
localStorage.setItem('teamup.delivery.product', productId)
void loadProduct(productId)
}
}, [productId, loadProduct])
const totals = useMemo(() => {
const counts: Record<string, number> = { Backlog: 0, InProgress: 0, InReview: 0, Done: 0 }
let total = 0
for (const t of teams) {
for (const c of COLUMNS) counts[c] += t.counts[c] ?? 0
total += t.total
}
const done = counts.Done
const pct = total > 0 ? Math.round((done / total) * 100) : 0
return { counts, total, done, pct, remaining: total - done }
}, [teams])
const product = products.find((p) => p.id === productId) ?? null
// Download the product as a zip of its delivered artifacts. The export endpoint streams a file, so we
// fetch it with the auth header (the api helper only does JSON), then trigger a browser download.
const downloadProject = useCallback(async () => {
if (!productId) return
setDownloading(true)
try {
const token = useAuth.getState().token
const res = await fetch(`/api/orgboard/products/${productId}/export`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error(`Export failed (${res.status})`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${(product?.name ?? 'project').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}.zip`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} catch (err) {
toast.error((err as Error).message)
} finally {
setDownloading(false)
}
}, [productId, product])
return (
<AppShell>
<div className="mx-auto max-w-4xl p-6">
<header className="mb-5 flex items-center justify-between gap-4">
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Gauge className="size-6" /> Delivery
</h1>
<div className="flex items-center gap-2">
{product && (
<Button variant="outline" size="sm" disabled={downloading} onClick={downloadProject}>
<Download data-icon="inline-start" />
{downloading ? 'Preparing…' : 'Download project'}
</Button>
)}
{products.length > 0 && (
<Select value={productId ?? ''} onValueChange={setProductId}>
<SelectTrigger className="w-56"><SelectValue placeholder="Pick a product" /></SelectTrigger>
<SelectContent>
<SelectGroup>
{products.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
)}
</div>
</header>
{product && (
<>
{/* Progress hero */}
<div className="mb-5 rounded-xl border bg-card/60 p-5 backdrop-blur-sm">
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-sm text-muted-foreground">{product.name}</p>
<p className="text-3xl font-semibold">{totals.pct}% complete</p>
<p className="text-sm text-muted-foreground">
{totals.done} of {totals.total} tasks done · {totals.remaining} remaining
</p>
</div>
</div>
<div className="mt-4 h-3 w-full overflow-hidden rounded-full bg-muted/60">
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${totals.pct}%` }} />
</div>
</div>
{/* Column breakdown */}
<div className="mb-5 grid grid-cols-2 gap-3 sm:grid-cols-4">
{COLUMNS.map((c) => (
<div key={c} className="rounded-lg border bg-card/50 p-3 backdrop-blur-sm">
<p className="text-xs text-muted-foreground">{LABEL[c]}</p>
<p className="text-2xl font-semibold">{totals.counts[c]}</p>
</div>
))}
</div>
{/* Quality (from analytics) */}
{analytics && (
<div className="mb-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
<Stat label="Approval rate" value={analytics.approvalRate == null ? '—' : `${Math.round(analytics.approvalRate * 100)}%`} />
<Stat label="Avg edit distance" value={analytics.avgEditDistance == null ? '—' : analytics.avgEditDistance.toFixed(2)} />
<Stat label="Awaiting review" value={String(analytics.pendingReviews)} />
</div>
)}
{/* Per team */}
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Teams</h2>
<div className="mb-6 flex flex-col gap-2">
{teams.map((t) => {
const pct = t.total > 0 ? Math.round((t.done / t.total) * 100) : 0
return (
<div key={t.team.id} className="flex items-center gap-3 rounded-lg border bg-card/50 px-3 py-2 backdrop-blur-sm">
<span className="w-40 shrink-0 truncate text-sm font-medium">{t.team.name}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted/60">
<div className="h-full rounded-full bg-approved" style={{ width: `${pct}%` }} />
</div>
<span className="w-24 shrink-0 text-right text-xs text-muted-foreground">{t.done}/{t.total} · {pct}%</span>
</div>
)
})}
{teams.length === 0 && <p className="text-sm text-muted-foreground">No teams on this product yet.</p>}
</div>
{/* Remaining */}
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Remaining ({totals.remaining})
</h2>
<div className="flex flex-col gap-1.5">
{teams.flatMap((t) => t.remaining.map((task) => ({ task, team: t.team.name }))).slice(0, 40).map(({ task, team }) => (
<div key={task.id} className="flex items-center gap-2 rounded-md border bg-card/40 px-3 py-1.5 text-sm backdrop-blur-sm">
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">{LABEL[task.status] ?? task.status}</span>
<span className="min-w-0 flex-1 truncate">{task.title}</span>
<span className="shrink-0 text-xs text-muted-foreground">{team}</span>
</div>
))}
{totals.remaining === 0 && <p className="text-sm text-muted-foreground">🎉 Nothing remaining everything is done.</p>}
</div>
</>
)}
</div>
</AppShell>
)
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border bg-card/50 p-3 backdrop-blur-sm">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold">{value}</p>
</div>
)
}
+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(Array.isArray(list) ? list : [])
setDivisions(Array.isArray(divs) ? 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>
)
}
+2
View File
@@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { REVIEWS_CHANGED } from '@/components/AppShell'
import { AgentFace } from '@/components/AgentFace'
import { MarkdownEditor } from '@/components/MarkdownEditor'
import { api } from '@/lib/api'
@@ -169,6 +170,7 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str
toast.info('Sent back to the agent')
}
onDecided(item.id)
window.dispatchEvent(new Event(REVIEWS_CHANGED))
} catch (err) {
toast.error((err as Error).message)
} finally {
Binary file not shown.
@@ -1,4 +1,5 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.Assembler.Domain;
using TeamUp.Modules.Assembler.Persistence;
using TeamUp.Modules.Assembler.Queue;
@@ -12,6 +13,18 @@ internal sealed class AgentRunDispatcher(AssemblerDbContext db, JobQueue queue,
{
public async Task<Guid> DispatchAsync(Guid seatId, Guid workItemId, CancellationToken cancellationToken = default)
{
// Defensive: never stack a duplicate run while one is already in flight for this (seat, task).
// Repeated "Run" clicks return the in-flight run instead of dispatching another model call.
var inFlight = await db.AgentRuns
.Where(r => r.SeatId == seatId && r.WorkItemId == workItemId
&& (r.Status == AgentRunStatus.Queued || r.Status == AgentRunStatus.Running))
.Select(r => (Guid?)r.Id)
.FirstOrDefaultAsync(cancellationToken);
if (inFlight is { } existing)
{
return existing;
}
var run = new AgentRun(seatId, workItemId, clock.GetUtcNow());
db.AgentRuns.Add(run);
await db.SaveChangesAsync(cancellationToken);
@@ -94,12 +94,20 @@ internal sealed class AgentRunExecutor(
run.Complete(output, assembled.PrimaryAction, assembled.PrimaryActionRisk, result, completion.LatencyMs, clock.GetUtcNow());
await db.SaveChangesAsync(cancellationToken);
// Only a spec / story-breakdown agent proposes child stories. Other actions (code, design,
// tests) produce an artifact, not a backlog — so don't mistake a numbered list in their
// output (e.g. a list of file names) for child tasks.
var primarySkill = context.SkillKeys.Count > 0 ? context.SkillKeys[0] : null;
var childTitles = primarySkill is "spec-writing" or "story-breakdown"
? OutputParser.ExtractChildTitles(output)
: Array.Empty<string>();
// Hand the parsed action to the gate: autonomy vs risk → execute now or hold in review.
var gate = await actionGate.EvaluateAsync(
new AgentActionProposal(
run.Id, run.SeatId, context.AgentId, run.WorkItemId, context.TeamId, context.OrganizationId,
context.Autonomy, assembled.PrimaryAction, assembled.PrimaryActionRisk,
context.TaskTitle, output, OutputParser.ExtractChildTitles(output), assembled.Trace),
context.TaskTitle, output, childTitles, assembled.Trace),
cancellationToken);
logger.LogInformation(
"Run {RunId}: {Action} ({Risk}) → {Outcome}.",
@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.Governance.Domain;
using TeamUp.Modules.Governance.Persistence;
using TeamUp.SharedKernel.Access;
@@ -25,6 +26,20 @@ internal sealed class ActionGate(
if (GatePolicy.ShouldHold(proposal.Autonomy, risk))
{
// Defensive: collapse duplicates. If an identical action is already pending for this
// (task, agent), keep that one rather than stacking another copy in the review inbox.
var existing = await db.ReviewItems
.Where(r => r.WorkItemId == proposal.WorkItemId
&& r.AgentId == proposal.AgentId
&& r.ActionKind == proposal.ActionKind
&& r.Status == ReviewStatus.Pending)
.Select(r => (Guid?)r.Id)
.FirstOrDefaultAsync(cancellationToken);
if (existing is { } existingId)
{
return new GateResult(GateOutcome.Held, existingId);
}
var item = new ReviewItem(proposal, clock.GetUtcNow());
db.ReviewItems.Add(item);
await db.SaveChangesAsync(cancellationToken);
@@ -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;
}
}
@@ -69,6 +69,9 @@ internal sealed class WorkItem : Entity
UpdatedAtUtc = nowUtc;
}
/// <summary>Detach from a parent (used when the parent is deleted, so the child stays on the board).</summary>
public void ClearParent() => ParentId = null;
/// <summary>Appends an approved agent artifact (spec / test plan) to the task.</summary>
public void AttachArtifact(string content, DateTimeOffset 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);
}
@@ -29,6 +29,13 @@ internal sealed record MoveTaskRequest(WorkItemStatus Status);
internal sealed record AssignTaskRequest(Guid MemberId);
// Autopilot: fan a parent task's outstanding children out to the team's AI seats in one shot.
internal sealed record RunAllRequest(Guid? SeatId = null);
internal sealed record RunDispatch(Guid WorkItemId, string Title, Guid SeatId, Guid RunId);
internal sealed record RunAllResponse(int Dispatched, IReadOnlyList<RunDispatch> Runs);
internal sealed record TaskResponse(
Guid Id,
Guid TeamId,
@@ -72,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);
@@ -1,3 +1,7 @@
using System.Globalization;
using System.IO.Compression;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
@@ -5,6 +9,7 @@ using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.OrgBoard.Domain;
using TeamUp.Modules.OrgBoard.Persistence;
using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Ai;
using TeamUp.SharedKernel.Auditing;
using TeamUp.SharedKernel.Modularity;
@@ -24,12 +29,15 @@ internal static class OrgBoardEndpoints
group.MapGet("/products", ListProducts).RequireAuthorization();
group.MapGet("/products/{id:guid}/identity", GetProductIdentity).RequireAuthorization();
group.MapPut("/products/{id:guid}/identity", SetProductIdentity).RequireAuthorization();
group.MapGet("/products/{id:guid}/export", ExportProduct).RequireAuthorization();
group.MapPost("/teams", CreateTeam).RequireAuthorization();
group.MapGet("/teams", ListTeams).RequireAuthorization();
group.MapPost("/tasks", CreateTask).RequireAuthorization();
group.MapGet("/board", GetBoard).RequireAuthorization();
group.MapPatch("/tasks/{id:guid}/move", MoveTask).RequireAuthorization();
group.MapPatch("/tasks/{id:guid}/assign", AssignTask).RequireAuthorization();
group.MapPost("/tasks/{id:guid}/run-all", RunAllChildren).RequireAuthorization();
group.MapDelete("/tasks/{id:guid}", DeleteTask).RequireAuthorization();
group.MapGet("/cartable", Cartable).RequireAuthorization();
group.MapPost("/seats", CreateSeat).RequireAuthorization();
@@ -41,6 +49,7 @@ internal static class OrgBoardEndpoints
AgentProfileEndpoints.MapTo(group);
ProductProfileEndpoints.MapTo(group);
ChangeRequestEndpoints.MapTo(group);
}
private static TaskResponse ToResponse(WorkItem item) => new(
@@ -229,6 +238,154 @@ internal static class OrgBoardEndpoints
return Results.Ok(new ProductIdentityResponse(product.Id, product.Name, product.Identity));
}
// Matches a fenced code block, capturing its language hint and body, so we can write a delivered
// artifact out as a real source file (App.tsx, schema.sql, …) instead of a wall of markdown.
private static readonly Regex FenceRx = new(
@"```(?<lang>[a-zA-Z0-9+#.]*)\s*\n(?<body>.*?)```",
RegexOptions.Singleline | RegexOptions.Compiled);
// Download the product as a project: PRODUCT.md + every team's delivered artifacts as files,
// plus a README manifest. This is the "an agent did the work, now I download the result" payoff —
// a portable bundle of what the team (human + AI) produced, gated on board-view permission.
private static async Task<IResult> ExportProduct(
Guid id, IPermissionService permissions, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var product = await db.Products.FirstOrDefaultAsync(p => p.Id == id, ct);
if (product is null)
{
return Results.NotFound();
}
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(product.OrganizationId)))
{
return Results.Forbid();
}
var teams = await db.Teams
.Where(t => t.ProductId == id)
.OrderBy(t => t.CreatedAtUtc)
.ToListAsync(ct);
var teamIds = teams.Select(t => t.Id).ToList();
var teamsById = teams.ToDictionary(t => t.Id);
// Only items that actually carry a delivered artifact are worth exporting.
var items = await db.WorkItems
.Where(w => teamIds.Contains(w.TeamId) && w.Description != null && w.Description != "")
.OrderBy(w => w.CreatedAtUtc)
.ToListAsync(ct);
using var buffer = new MemoryStream();
using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
{
var manifest = new StringBuilder();
manifest.Append("# ").Append(product.Name).Append("\n\n");
manifest.Append("_Exported from TeamUp on ")
.Append(clock.GetUtcNow().ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture)).Append("._\n\n");
manifest.Append(items.Count).Append(" delivered artifact(s) across ")
.Append(teams.Count).Append(" team(s).\n\n");
if (!string.IsNullOrWhiteSpace(product.Identity))
{
WriteEntry(zip, "PRODUCT.md", product.Identity!);
manifest.Append("- `PRODUCT.md` — product identity\n");
}
var counters = new Dictionary<Guid, int>();
foreach (var item in items)
{
var team = teamsById[item.TeamId];
var folder = Slug(team.Name);
var n = counters.TryGetValue(item.TeamId, out var c) ? c + 1 : 1;
counters[item.TeamId] = n;
var (ext, content) = RenderArtifact(item.Description!);
var path = $"{folder}/{n:D2}-{Slug(item.Title)}{ext}";
WriteEntry(zip, path, content);
manifest.Append("- `").Append(path).Append("` — ").Append(item.Title).Append('\n');
}
WriteEntry(zip, "README.md", manifest.ToString());
}
return Results.File(buffer.ToArray(), "application/zip", $"{Slug(product.Name)}.zip");
}
private static void WriteEntry(ZipArchive zip, string path, string content)
{
var entry = zip.CreateEntry(path, CompressionLevel.Optimal);
using var stream = entry.Open();
using var writer = new StreamWriter(stream, new UTF8Encoding(false));
writer.Write(content);
}
// If a delivered artifact is essentially one fenced code block, write it out as that source file;
// otherwise keep it as markdown.
private static (string Ext, string Content) RenderArtifact(string artifact)
{
var matches = FenceRx.Matches(artifact);
if (matches.Count > 0)
{
var largest = matches
.OrderByDescending(m => m.Groups["body"].Value.Length)
.First();
var body = largest.Groups["body"].Value;
if (body.Length >= artifact.Trim().Length * 0.5)
{
return (ExtensionFor(largest.Groups["lang"].Value), body.TrimEnd() + "\n");
}
}
return (".md", artifact);
}
private static string ExtensionFor(string lang) => lang.ToLowerInvariant() switch
{
"tsx" => ".tsx",
"ts" or "typescript" => ".ts",
"jsx" => ".jsx",
"js" or "javascript" => ".js",
"cs" or "csharp" => ".cs",
"py" or "python" => ".py",
"go" or "golang" => ".go",
"java" => ".java",
"rb" or "ruby" => ".rb",
"php" => ".php",
"rs" or "rust" => ".rs",
"sql" => ".sql",
"html" => ".html",
"css" => ".css",
"scss" => ".scss",
"json" => ".json",
"yaml" or "yml" => ".yml",
"sh" or "bash" or "shell" => ".sh",
"md" or "markdown" => ".md",
_ => ".txt",
};
// Filesystem-safe lower-kebab slug for folder/file names.
private static string Slug(string value)
{
var sb = new StringBuilder(value.Length);
foreach (var ch in value.Trim().ToLowerInvariant())
{
sb.Append(char.IsLetterOrDigit(ch) ? ch : '-');
}
var slug = sb.ToString();
while (slug.Contains("--", StringComparison.Ordinal))
{
slug = slug.Replace("--", "-", StringComparison.Ordinal);
}
slug = slug.Trim('-');
if (slug.Length == 0)
{
return "item";
}
return slug.Length > 50 ? slug[..50].Trim('-') : slug;
}
private static async Task<IResult> ListTeams(
Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
{
@@ -273,6 +430,36 @@ internal static class OrgBoardEndpoints
return Results.Ok(ToResponse(item));
}
// Remove a task from the board. Its children are detached (kept as top-level) rather than deleted,
// and its status-transition history is dropped. Any agent runs/reviews it spawned are left as history.
private static async Task<IResult> DeleteTask(
Guid id, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, CancellationToken ct)
{
var item = await db.WorkItems.FirstOrDefaultAsync(w => w.Id == id, ct);
if (item is null)
{
return Results.NotFound();
}
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == item.TeamId, ct);
if (team is null || !permissions.Has(Capability.WorkTasks, ScopeRef.Team(item.TeamId), ScopeRef.Org(team.OrganizationId)))
{
return Results.Forbid();
}
foreach (var child in await db.WorkItems.Where(w => w.ParentId == id).ToListAsync(ct))
{
child.ClearParent();
}
db.Transitions.RemoveRange(await db.Transitions.Where(t => t.WorkItemId == id).ToListAsync(ct));
db.WorkItems.Remove(item);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("task.deleted", "WorkItem", id, user.MemberId, item.Title), ct);
return Results.NoContent();
}
private static async Task<IResult> GetBoard(
Guid teamId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
{
@@ -354,6 +541,76 @@ internal static class OrgBoardEndpoints
return Results.Ok(ToResponse(item));
}
// Autopilot for a big task: dispatch a run for every outstanding child in one shot, fanning them
// across the team's configured AI seats (round-robin), or all to one seat if specified. Children
// that already carry an artifact or are done are skipped, so re-running only picks up what's left.
// The agents still act per their own autonomy — gated ones land in review; this just starts them all.
private static async Task<IResult> RunAllChildren(
Guid id, RunAllRequest request, ICurrentUser user, IPermissionService permissions,
IAgentDispatcher dispatcher, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var parent = await db.WorkItems.FirstOrDefaultAsync(w => w.Id == id, ct);
if (parent is null)
{
return Results.NotFound("Task not found.");
}
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == parent.TeamId, ct);
if (team is null)
{
return Results.NotFound("Team not found.");
}
if (!permissions.Has(Capability.WorkTasks, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
{
return Results.Forbid();
}
// Outstanding children: not done and no delivered artifact yet.
var children = await db.WorkItems
.Where(w => w.ParentId == id
&& w.Status != WorkItemStatus.Done
&& (w.Description == null || w.Description == ""))
.OrderBy(w => w.CreatedAtUtc)
.ToListAsync(ct);
if (children.Count == 0)
{
return Results.Ok(new RunAllResponse(0, Array.Empty<RunDispatch>()));
}
// The pool of AI seats to run them on: configured AI seats on the team (optionally just one).
var seats = await db.Seats
.Where(s => s.TeamId == team.Id && s.State == SeatState.Ai && s.AgentId != null)
.OrderBy(s => s.CreatedAtUtc)
.ToListAsync(ct);
if (request.SeatId is { } chosen)
{
seats = seats.Where(s => s.Id == chosen).ToList();
}
if (seats.Count == 0)
{
return Results.BadRequest("No configured AI seat on this team to run the tasks.");
}
var now = clock.GetUtcNow();
var dispatched = new List<RunDispatch>(children.Count);
for (var i = 0; i < children.Count; i++)
{
var seat = seats[i % seats.Count];
var runId = await dispatcher.DispatchAsync(seat.Id, children[i].Id, ct);
if (children[i].Status == WorkItemStatus.Backlog)
{
children[i].MoveTo(WorkItemStatus.InProgress, now);
}
dispatched.Add(new RunDispatch(children[i].Id, children[i].Title, seat.Id, runId));
}
await db.SaveChangesAsync(ct);
return Results.Ok(new RunAllResponse(dispatched.Count, dispatched));
}
private static async Task<IResult> Cartable(ICurrentUser user, OrgBoardDbContext db, CancellationToken ct)
{
var memberId = user.MemberId;
@@ -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);
});
}
}
@@ -35,7 +35,14 @@ internal sealed class BoardWriter(OrgBoardDbContext db, TimeProvider clock) : IB
var item = await db.WorkItems.FirstOrDefaultAsync(w => w.Id == workItemId, cancellationToken)
?? throw new InvalidOperationException($"Work item {workItemId} not found.");
item.AttachArtifact(content, clock.GetUtcNow());
var now = clock.GetUtcNow();
item.AttachArtifact(content, now);
// Surface the delivered artifact on the board: move it into review (unless already done).
if (item.Status is WorkItemStatus.Backlog or WorkItemStatus.InProgress)
{
item.MoveTo(WorkItemStatus.InReview, now);
}
await db.SaveChangesAsync(cancellationToken);
}
}