img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/client/src/components/ui/input.tsx b/client/src/components/ui/input.tsx
new file mode 100644
index 0000000..d763cd9
--- /dev/null
+++ b/client/src/components/ui/input.tsx
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/client/src/components/ui/label.tsx b/client/src/components/ui/label.tsx
new file mode 100644
index 0000000..f752f82
--- /dev/null
+++ b/client/src/components/ui/label.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+import { Label as LabelPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Label({
+ className,
+ ...props
+}: React.ComponentProps
) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/client/src/components/ui/select.tsx b/client/src/components/ui/select.tsx
new file mode 100644
index 0000000..f09dfb4
--- /dev/null
+++ b/client/src/components/ui/select.tsx
@@ -0,0 +1,192 @@
+"use client"
+
+import * as React from "react"
+import { Select as SelectPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+
+function Select({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "item-aligned",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/client/src/components/ui/separator.tsx b/client/src/components/ui/separator.tsx
new file mode 100644
index 0000000..ca11501
--- /dev/null
+++ b/client/src/components/ui/separator.tsx
@@ -0,0 +1,26 @@
+import * as React from "react"
+import { Separator as SeparatorPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/client/src/components/ui/skeleton.tsx b/client/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..0118624
--- /dev/null
+++ b/client/src/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/client/src/components/ui/sonner.tsx b/client/src/components/ui/sonner.tsx
new file mode 100644
index 0000000..9772eb2
--- /dev/null
+++ b/client/src/components/ui/sonner.tsx
@@ -0,0 +1,47 @@
+import { useTheme } from "next-themes"
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme()
+
+ return (
+
+ ),
+ info: (
+
+ ),
+ warning: (
+
+ ),
+ error: (
+
+ ),
+ loading: (
+
+ ),
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ toastOptions={{
+ classNames: {
+ toast: "cn-toast",
+ },
+ }}
+ {...props}
+ />
+ )
+}
+
+export { Toaster }
diff --git a/client/src/index.css b/client/src/index.css
index 65004fd..014ee45 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -1,10 +1,151 @@
@import "tailwindcss";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+@import "@fontsource-variable/hanken-grotesk";
+
+@custom-variant dark (&:is(.dark *));
:root {
- color-scheme: dark;
+ --radius: 0.625rem;
+
+ /* Light content surface — the "calm command center" body. */
+ --background: oklch(0.99 0.003 280);
+ --foreground: oklch(0.21 0.03 280);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.21 0.03 280);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.21 0.03 280);
+
+ /* Brand: indigo, rationed so it always means something. */
+ --primary: oklch(0.511 0.262 276.966);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.967 0.012 280);
+ --secondary-foreground: oklch(0.3 0.05 280);
+ --muted: oklch(0.967 0.006 280);
+ --muted-foreground: oklch(0.52 0.03 280);
+ --accent: oklch(0.95 0.03 280);
+ --accent-foreground: oklch(0.4 0.16 277);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.92 0.01 280);
+ --input: oklch(0.92 0.01 280);
+ --ring: oklch(0.585 0.233 277.117);
+
+ /* Seat-state triad (load-bearing) + status colors. */
+ --seat-human: oklch(0.554 0.046 257.417); /* slate */
+ --seat-open: oklch(0.769 0.188 70.08); /* amber */
+ --seat-ai: oklch(0.585 0.233 277.117); /* indigo */
+ --approved: oklch(0.704 0.14 182.503); /* teal */
+ --held: oklch(0.769 0.188 70.08); /* amber */
+
+ --chart-1: oklch(0.585 0.233 277.117);
+ --chart-2: oklch(0.704 0.14 182.503);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.554 0.046 257.417);
+ --chart-5: oklch(0.5 0.13 300);
+
+ /* Deep-indigo command-center sidebar. */
+ --sidebar: oklch(0.257 0.09 281.288);
+ --sidebar-foreground: oklch(0.93 0.02 280);
+ --sidebar-primary: oklch(0.673 0.182 276.935);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.359 0.144 278.697);
+ --sidebar-accent-foreground: oklch(0.97 0.01 280);
+ --sidebar-border: oklch(0.45 0.12 278 / 35%);
+ --sidebar-ring: oklch(0.585 0.233 277.117);
}
body {
margin: 0;
- font-family: "Hanken Grotesk", system-ui, sans-serif;
+ font-family: "Hanken Grotesk Variable", system-ui, sans-serif;
+}
+
+@theme inline {
+ --font-sans: "Hanken Grotesk Variable", system-ui, sans-serif;
+ --font-heading: var(--font-sans);
+
+ --color-seat-human: var(--seat-human);
+ --color-seat-open: var(--seat-open);
+ --color-seat-ai: var(--seat-ai);
+ --color-approved: var(--approved);
+ --color-held: var(--held);
+
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --color-foreground: var(--foreground);
+ --color-background: var(--background);
+ --radius-sm: calc(var(--radius) * 0.6);
+ --radius-md: calc(var(--radius) * 0.8);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) * 1.4);
+ --radius-2xl: calc(var(--radius) * 1.8);
+ --radius-3xl: calc(var(--radius) * 2.2);
+ --radius-4xl: calc(var(--radius) * 2.6);
+}
+
+.dark {
+ --background: oklch(0.205 0.03 280);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.257 0.04 281);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.257 0.04 281);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.673 0.182 276.935);
+ --primary-foreground: oklch(0.205 0.03 280);
+ --secondary: oklch(0.3 0.04 280);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.3 0.04 280);
+ --muted-foreground: oklch(0.72 0.03 280);
+ --accent: oklch(0.32 0.06 280);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.585 0.233 277.117);
+ --sidebar: oklch(0.21 0.07 281);
+ --sidebar-foreground: oklch(0.93 0.02 280);
+ --sidebar-primary: oklch(0.673 0.182 276.935);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.359 0.144 278.697);
+ --sidebar-accent-foreground: oklch(0.97 0.01 280);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.585 0.233 277.117);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+ html {
+ @apply font-sans;
+ }
}
diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts
new file mode 100644
index 0000000..7f67bc0
--- /dev/null
+++ b/client/src/lib/api.ts
@@ -0,0 +1,31 @@
+import { useAuth } from '../store/auth'
+
+async function request(method: string, url: string, body?: unknown): Promise {
+ const token = useAuth.getState().token
+ const response = await fetch(url, {
+ method,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ })
+
+ if (response.status === 401) {
+ useAuth.getState().logout()
+ }
+
+ if (!response.ok) {
+ const text = await response.text()
+ throw new Error(`${response.status} ${response.statusText}${text ? `: ${text}` : ''}`)
+ }
+
+ const contentType = response.headers.get('content-type') ?? ''
+ return contentType.includes('application/json') ? ((await response.json()) as T) : (undefined as T)
+}
+
+export const api = {
+ get: (url: string) => request('GET', url),
+ post: (url: string, body?: unknown) => request('POST', url, body),
+ patch: (url: string, body?: unknown) => request('PATCH', url, body),
+}
diff --git a/client/src/lib/utils.ts b/client/src/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/client/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/client/src/main.tsx b/client/src/main.tsx
index bef5202..ae3d161 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -1,10 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
+import { BrowserRouter } from 'react-router'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
-
+
+
+
,
)
diff --git a/client/src/pages/BoardPage.tsx b/client/src/pages/BoardPage.tsx
new file mode 100644
index 0000000..2d62bf2
--- /dev/null
+++ b/client/src/pages/BoardPage.tsx
@@ -0,0 +1,317 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Plus, UserPlus } from 'lucide-react'
+import { toast } from 'sonner'
+import { AppShell } from '@/components/AppShell'
+import { StatusDot } from '@/components/StatusDot'
+import { Avatar, AvatarFallback } from '@/components/ui/avatar'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { api } from '@/lib/api'
+import { useAuth } from '@/store/auth'
+
+const COLUMNS = [
+ { value: 'Backlog', label: 'Backlog' },
+ { value: 'InProgress', label: 'In Progress' },
+ { value: 'InReview', label: 'In Review' },
+ { value: 'Done', label: 'Done' },
+] as const
+
+interface Team {
+ id: string
+ organizationId: string
+ name: string
+}
+
+interface Task {
+ id: string
+ teamId: string
+ title: string
+ type: string
+ status: string
+ assigneeKind: string
+ assigneeId?: string | null
+}
+
+interface Board {
+ teamId: string
+ columns: { status: string; items: Task[] }[]
+}
+
+export function BoardPage() {
+ const memberId = useAuth((s) => s.memberId)
+ const email = useAuth((s) => s.email)
+ const organizationId = useAuth((s) => s.organizationId)
+
+ const [orgName, setOrgName] = useState('')
+ const [teams, setTeams] = useState([])
+ const [teamId, setTeamId] = useState(null)
+ const [board, setBoard] = useState(null)
+ const [cartable, setCartable] = useState([])
+ const [newTeam, setNewTeam] = useState('')
+ const [newTask, setNewTask] = useState('')
+
+ const loadTeams = useCallback(async () => {
+ if (!organizationId) {
+ return
+ }
+ try {
+ const result = await api.get(`/api/orgboard/teams?organizationId=${organizationId}`)
+ setTeams(result)
+ setTeamId((current) => current ?? result[0]?.id ?? null)
+ } catch (err) {
+ toast.error((err as Error).message)
+ }
+ }, [organizationId])
+
+ const loadBoard = useCallback(async (id: string) => {
+ try {
+ setBoard(await api.get(`/api/orgboard/board?teamId=${id}`))
+ setCartable(await api.get('/api/orgboard/cartable'))
+ } catch (err) {
+ toast.error((err as Error).message)
+ }
+ }, [])
+
+ useEffect(() => {
+ void loadTeams()
+ }, [loadTeams])
+
+ useEffect(() => {
+ if (teamId) {
+ void loadBoard(teamId)
+ }
+ }, [teamId, loadBoard])
+
+ async function run(action: () => Promise) {
+ try {
+ await action()
+ } catch (err) {
+ toast.error((err as Error).message)
+ }
+ }
+
+ const saveOrg = () =>
+ run(async () => {
+ await api.post('/api/orgboard/organizations', { organizationId, name: orgName })
+ toast.success('Organization saved.')
+ })
+
+ const createTeam = () =>
+ run(async () => {
+ const team = await api.post('/api/orgboard/teams', { organizationId, name: newTeam })
+ setNewTeam('')
+ await loadTeams()
+ setTeamId(team.id)
+ })
+
+ const createTask = () =>
+ run(async () => {
+ if (!teamId) {
+ return
+ }
+ await api.post('/api/orgboard/tasks', { teamId, title: newTask, type: 'Story' })
+ setNewTask('')
+ await loadBoard(teamId)
+ })
+
+ const move = (id: string, status: string) =>
+ run(async () => {
+ await api.patch(`/api/orgboard/tasks/${id}/move`, { status })
+ if (teamId) {
+ await loadBoard(teamId)
+ }
+ })
+
+ const assignToMe = (id: string) =>
+ run(async () => {
+ await api.patch(`/api/orgboard/tasks/${id}/assign`, { memberId })
+ if (teamId) {
+ await loadBoard(teamId)
+ }
+ })
+
+ const initials = (email ?? '?').slice(0, 2).toUpperCase()
+
+ return (
+
+
+
+
+
+
+ Setup
+ Name the org, create a team, and pick one to view its board.
+
+
+
+
+
+ setOrgName(e.target.value)} className="w-48" />
+
+
+
+
+
+
+
+
setNewTeam(e.target.value)} className="w-48" />
+
+
+
+
+
+
+
+
+
+
+
+ {teamId && (
+
+
setNewTask(e.target.value)}
+ placeholder="New task title…"
+ className="max-w-md"
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ createTask()
+ }
+ }}
+ />
+
+
+ )}
+
+
+ {COLUMNS.map((column) => {
+ const items = board?.columns.find((c) => c.status === column.value)?.items ?? []
+ return (
+
+
+
+ {column.label}
+ {items.length}
+
+
+
+ {items.map((task) => {
+ const mine = task.assigneeKind === 'Member' && task.assigneeId === memberId
+ return (
+
+
+
+ {task.title}
+ {task.type}
+
+
+
+
+ {task.assigneeKind === 'Member' ? (
+
+
+
+
+ {mine ? initials : '··'}
+
+
+ {mine ? 'You' : 'Assigned'}
+
+ ) : (
+
+ )}
+
+
+
+ )
+ })}
+ {items.length === 0 && (
+ No tasks
+ )}
+
+
+ )
+ })}
+
+
+
+
+ My cartable
+ Tasks assigned to you across teams.
+
+
+ {cartable.map((task) => (
+
+ {task.title}
+ {task.status}
+
+ ))}
+ {cartable.length === 0 && (
+ Nothing assigned to you yet.
+ )}
+
+
+
+
+ )
+}
diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..c24217d
--- /dev/null
+++ b/client/src/pages/LoginPage.tsx
@@ -0,0 +1,123 @@
+import { type FormEvent, useState } from 'react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { api } from '@/lib/api'
+import { useAuth } from '@/store/auth'
+
+interface AuthResponse {
+ token: string
+ memberId: string
+}
+
+interface BootstrapResponse {
+ token: string
+ memberId: string
+ organizationId: string
+}
+
+interface MeResponse {
+ email: string
+ memberships: { scopeType: string; scopeId: string; role: string }[]
+}
+
+export function LoginPage() {
+ const setAuth = useAuth((state) => state.setAuth)
+ const [mode, setMode] = useState<'login' | 'bootstrap'>('login')
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [displayName, setDisplayName] = useState('')
+ const [orgName, setOrgName] = useState('')
+ const [busy, setBusy] = useState(false)
+
+ async function submit(event: FormEvent) {
+ event.preventDefault()
+ setBusy(true)
+ try {
+ if (mode === 'bootstrap') {
+ const result = await api.post('/api/identity/bootstrap', {
+ organizationName: orgName,
+ ownerEmail: email,
+ ownerDisplayName: displayName,
+ ownerPassword: password,
+ })
+ setAuth(result.token, result.memberId, result.organizationId, email)
+ } else {
+ const result = await api.post('/api/identity/auth/login', { email, password })
+ setAuth(result.token, result.memberId, null, email)
+ const me = await api.get('/api/identity/me')
+ const org = me.memberships.find((m) => m.scopeType === 'Organization')
+ setAuth(result.token, result.memberId, org?.scopeId ?? null, me.email)
+ }
+ } catch (err) {
+ toast.error((err as Error).message)
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+ T
+
+ TeamUp.AI
+
+
+ {mode === 'login' ? 'Sign in to your command center.' : 'Create the first owner of a new org.'}
+
+
+
+
+
+
+
+ )
+}
+
+function LabeledInput(props: {
+ id: string
+ label: string
+ value: string
+ onChange: (value: string) => void
+ type?: string
+}) {
+ return (
+
+
+ props.onChange(event.target.value)}
+ required
+ />
+
+ )
+}
diff --git a/client/src/store/auth.ts b/client/src/store/auth.ts
new file mode 100644
index 0000000..236e575
--- /dev/null
+++ b/client/src/store/auth.ts
@@ -0,0 +1,26 @@
+import { create } from 'zustand'
+import { persist } from 'zustand/middleware'
+
+interface AuthState {
+ token: string | null
+ memberId: string | null
+ organizationId: string | null
+ email: string | null
+ setAuth: (token: string, memberId: string, organizationId: string | null, email?: string | null) => void
+ logout: () => void
+}
+
+export const useAuth = create()(
+ persist(
+ (set) => ({
+ token: null,
+ memberId: null,
+ organizationId: null,
+ email: null,
+ setAuth: (token, memberId, organizationId, email = null) =>
+ set({ token, memberId, organizationId, email }),
+ logout: () => set({ token: null, memberId: null, organizationId: null, email: null }),
+ }),
+ { name: 'teamup-auth' },
+ ),
+)
diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json
index 7f42e5f..7904b1a 100644
--- a/client/tsconfig.app.json
+++ b/client/tsconfig.app.json
@@ -6,6 +6,9 @@
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
+ "paths": {
+ "@/*": ["./src/*"]
+ },
/* Bundler mode */
"moduleResolution": "bundler",
diff --git a/client/tsconfig.json b/client/tsconfig.json
index 1ffef60..c36d52a 100644
--- a/client/tsconfig.json
+++ b/client/tsconfig.json
@@ -3,5 +3,10 @@
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
- ]
+ ],
+ "compilerOptions": {
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
}
diff --git a/client/vite.config.ts b/client/vite.config.ts
index b3b7186..d68582c 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -1,3 +1,4 @@
+import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
@@ -7,6 +8,11 @@ import tailwindcss from '@tailwindcss/vite'
// Prod: `npm run build` emits ./dist, which the .NET publish step / Docker copies into wwwroot.
export default defineConfig({
plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ },
+ },
server: {
port: 5173,
proxy: {
diff --git a/src/Hosts/TeamUp.Web/Program.cs b/src/Hosts/TeamUp.Web/Program.cs
index 780e904..77714eb 100644
--- a/src/Hosts/TeamUp.Web/Program.cs
+++ b/src/Hosts/TeamUp.Web/Program.cs
@@ -1,3 +1,4 @@
+using System.Text.Json.Serialization;
using OpenTelemetry.Trace;
using Serilog;
using TeamUp.Bootstrap;
@@ -12,6 +13,10 @@ builder.Host.UseSerilog((context, services, configuration) => configuration
builder.Services.AddOpenApi();
+// Bind/serialize enums as strings across the API (e.g. ScopeType "Organization", RoleType "Member").
+builder.Services.ConfigureHttpJsonOptions(options =>
+ options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
+
builder.Services.AddTeamUpObservability(
builder.Configuration,
serviceName: "teamup-web",
@@ -42,6 +47,9 @@ app.UseSerilogRequestLogging();
app.UseDefaultFiles();
app.UseStaticFiles();
+app.UseAuthentication();
+app.UseAuthorization();
+
app.MapHealthChecks("/health");
app.MapTeamUpModules();
diff --git a/src/Hosts/TeamUp.Web/appsettings.json b/src/Hosts/TeamUp.Web/appsettings.json
index db21550..f23be41 100644
--- a/src/Hosts/TeamUp.Web/appsettings.json
+++ b/src/Hosts/TeamUp.Web/appsettings.json
@@ -5,6 +5,12 @@
"Database": {
"ApplyMigrationsOnStartup": false
},
+ "Jwt": {
+ "Secret": "dev-only-teamup-jwt-signing-secret-change-in-production-0123456789",
+ "Issuer": "teamup",
+ "Audience": "teamup",
+ "ExpiryMinutes": 480
+ },
"OpenTelemetry": {
"OtlpEndpoint": ""
},
diff --git a/src/Hosts/TeamUp.Worker/appsettings.json b/src/Hosts/TeamUp.Worker/appsettings.json
index f0d96a0..c17bfab 100644
--- a/src/Hosts/TeamUp.Worker/appsettings.json
+++ b/src/Hosts/TeamUp.Worker/appsettings.json
@@ -5,6 +5,12 @@
"Database": {
"ApplyMigrationsOnStartup": false
},
+ "Jwt": {
+ "Secret": "dev-only-teamup-jwt-signing-secret-change-in-production-0123456789",
+ "Issuer": "teamup",
+ "Audience": "teamup",
+ "ExpiryMinutes": 480
+ },
"OpenTelemetry": {
"OtlpEndpoint": ""
},
diff --git a/src/Modules/TeamUp.Modules.Governance/Auditing/AuditLog.cs b/src/Modules/TeamUp.Modules.Governance/Auditing/AuditLog.cs
new file mode 100644
index 0000000..c9432b7
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Auditing/AuditLog.cs
@@ -0,0 +1,25 @@
+using TeamUp.Modules.Governance.Domain;
+using TeamUp.Modules.Governance.Persistence;
+using TeamUp.SharedKernel.Auditing;
+
+namespace TeamUp.Modules.Governance.Auditing;
+
+///
+/// Writes audit events to the governance store. Uses its own DbContext/transaction (best-effort,
+/// decoupled from the acting module's unit of work) — sufficient for M1.
+///
+internal sealed class AuditLog(GovernanceDbContext db, TimeProvider clock) : IAuditLog
+{
+ public async Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
+ {
+ db.AuditEntries.Add(new AuditEntry(
+ auditEvent.Action,
+ auditEvent.EntityType,
+ auditEvent.EntityId,
+ auditEvent.ActorMemberId,
+ auditEvent.Details,
+ clock.GetUtcNow()));
+
+ await db.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/Domain/AuditEntry.cs b/src/Modules/TeamUp.Modules.Governance/Domain/AuditEntry.cs
new file mode 100644
index 0000000..777e853
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Domain/AuditEntry.cs
@@ -0,0 +1,34 @@
+using TeamUp.SharedKernel.Domain;
+
+namespace TeamUp.Modules.Governance.Domain;
+
+/// An immutable audit record. Append-only — never updated or deleted.
+internal sealed class AuditEntry : Entity
+{
+ public string Action { get; private set; } = null!;
+ public string EntityType { get; private set; } = null!;
+ public Guid EntityId { get; private set; }
+ public Guid? ActorMemberId { get; private set; }
+ public string? Details { get; private set; }
+ public DateTimeOffset OccurredAtUtc { get; private set; }
+
+ private AuditEntry()
+ {
+ }
+
+ public AuditEntry(
+ string action,
+ string entityType,
+ Guid entityId,
+ Guid? actorMemberId,
+ string? details,
+ DateTimeOffset occurredAtUtc)
+ {
+ Action = action;
+ EntityType = entityType;
+ EntityId = entityId;
+ ActorMemberId = actorMemberId;
+ Details = details;
+ OccurredAtUtc = occurredAtUtc;
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/Endpoints/GovernanceEndpoints.cs b/src/Modules/TeamUp.Modules.Governance/Endpoints/GovernanceEndpoints.cs
new file mode 100644
index 0000000..bbde94a
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Endpoints/GovernanceEndpoints.cs
@@ -0,0 +1,49 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.EntityFrameworkCore;
+using TeamUp.Modules.Governance.Persistence;
+using TeamUp.SharedKernel.Access;
+using TeamUp.SharedKernel.Modularity;
+
+namespace TeamUp.Modules.Governance.Endpoints;
+
+internal sealed record AuditEntryResponse(
+ Guid Id,
+ string Action,
+ string EntityType,
+ Guid EntityId,
+ Guid? ActorMemberId,
+ string? Details,
+ DateTimeOffset OccurredAtUtc);
+
+internal static class GovernanceEndpoints
+{
+ public static void Map(IEndpointRouteBuilder endpoints)
+ {
+ var group = endpoints.MapGroup("/api/governance").WithTags("Governance");
+
+ group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("governance")));
+ group.MapGet("/audit", GetAudit).RequireAuthorization();
+ }
+
+ private static async Task GetAudit(
+ Guid organizationId, int? take, IPermissionService permissions, GovernanceDbContext db, CancellationToken ct)
+ {
+ // Owner-only. (M1 audit entries are not yet org-scoped — fine for single-org dogfood.)
+ if (!permissions.Has(Capability.ViewAuditLog, ScopeRef.Org(organizationId)))
+ {
+ return Results.Forbid();
+ }
+
+ var limit = Math.Clamp(take ?? 100, 1, 500);
+ var entries = await db.AuditEntries
+ .OrderByDescending(a => a.OccurredAtUtc)
+ .Take(limit)
+ .Select(a => new AuditEntryResponse(
+ a.Id, a.Action, a.EntityType, a.EntityId, a.ActorMemberId, a.Details, a.OccurredAtUtc))
+ .ToListAsync(ct);
+
+ return Results.Ok(entries);
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/GovernanceModule.cs b/src/Modules/TeamUp.Modules.Governance/GovernanceModule.cs
index c46f420..b7bccef 100644
--- a/src/Modules/TeamUp.Modules.Governance/GovernanceModule.cs
+++ b/src/Modules/TeamUp.Modules.Governance/GovernanceModule.cs
@@ -1,27 +1,32 @@
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
+using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using TeamUp.Modules.Governance.Auditing;
+using TeamUp.Modules.Governance.Endpoints;
+using TeamUp.Modules.Governance.Persistence;
+using TeamUp.SharedKernel.Auditing;
using TeamUp.SharedKernel.Modularity;
+using TeamUp.SharedKernel.Persistence;
namespace TeamUp.Modules.Governance;
-/// Autonomy dial, the action gate, the review inbox, the audit log (M5).
+/// Autonomy dial, the action gate, the review inbox, the audit log (M5). M1 ships the audit log.
public sealed class GovernanceModule : IModule
{
public string Name => "governance";
public void Register(IServiceCollection services, IConfiguration configuration)
{
- // Skeleton: no services yet. M5 introduces the action gate, ReviewItem context,
- // edit-distance capture, and the immutable audit log here.
+ var connectionString = configuration.GetConnectionString("Postgres")
+ ?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'.");
+
+ services.AddDbContext(options => options.UseNpgsql(connectionString));
+ services.AddScoped(sp => sp.GetRequiredService());
+ services.AddScoped();
+ services.TryAddSingleton(TimeProvider.System);
}
- public void MapEndpoints(IEndpointRouteBuilder endpoints)
- {
- endpoints.MapGroup($"/api/{Name}")
- .WithTags("Governance")
- .MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name)));
- }
+ public void MapEndpoints(IEndpointRouteBuilder endpoints) => GovernanceEndpoints.Map(endpoints);
}
diff --git a/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContext.cs b/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContext.cs
new file mode 100644
index 0000000..637a598
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContext.cs
@@ -0,0 +1,27 @@
+using Microsoft.EntityFrameworkCore;
+using TeamUp.Modules.Governance.Domain;
+using TeamUp.SharedKernel.Persistence;
+
+namespace TeamUp.Modules.Governance.Persistence;
+
+internal sealed class GovernanceDbContext(DbContextOptions options)
+ : DbContext(options), IModuleDbContext
+{
+ public DbSet AuditEntries => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasDefaultSchema("governance");
+
+ modelBuilder.Entity(entry =>
+ {
+ entry.ToTable("audit_entries");
+ entry.HasKey(a => a.Id);
+ entry.Property(a => a.Action).HasMaxLength(100).IsRequired();
+ entry.Property(a => a.EntityType).HasMaxLength(100).IsRequired();
+ entry.Property(a => a.Details).HasMaxLength(2000);
+ entry.HasIndex(a => a.OccurredAtUtc);
+ entry.HasIndex(a => new { a.EntityType, a.EntityId });
+ });
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContextFactory.cs b/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContextFactory.cs
new file mode 100644
index 0000000..c480e93
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContextFactory.cs
@@ -0,0 +1,21 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace TeamUp.Modules.Governance.Persistence;
+
+/// Design-time factory so `dotnet ef` can build the internal context without a host.
+internal sealed class GovernanceDbContextFactory : IDesignTimeDbContextFactory
+{
+ public GovernanceDbContext CreateDbContext(string[] args)
+ {
+ var connectionString =
+ Environment.GetEnvironmentVariable("ConnectionStrings__Postgres")
+ ?? "Host=localhost;Port=5432;Database=teamup;Username=teamup;Password=teamup";
+
+ var options = new DbContextOptionsBuilder()
+ .UseNpgsql(connectionString)
+ .Options;
+
+ return new GovernanceDbContext(options);
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260609084417_InitialGovernance.Designer.cs b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260609084417_InitialGovernance.Designer.cs
new file mode 100644
index 0000000..07d0a63
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260609084417_InitialGovernance.Designer.cs
@@ -0,0 +1,69 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using TeamUp.Modules.Governance.Persistence;
+
+#nullable disable
+
+namespace TeamUp.Modules.Governance.Persistence.Migrations
+{
+ [DbContext(typeof(GovernanceDbContext))]
+ [Migration("20260609084417_InitialGovernance")]
+ partial class InitialGovernance
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("governance")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("TeamUp.Modules.Governance.Domain.AuditEntry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ActorMemberId")
+ .HasColumnType("uuid");
+
+ b.Property("Details")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("EntityId")
+ .HasColumnType("uuid");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("OccurredAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OccurredAtUtc");
+
+ b.HasIndex("EntityType", "EntityId");
+
+ b.ToTable("audit_entries", "governance");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260609084417_InitialGovernance.cs b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260609084417_InitialGovernance.cs
new file mode 100644
index 0000000..c391468
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260609084417_InitialGovernance.cs
@@ -0,0 +1,56 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace TeamUp.Modules.Governance.Persistence.Migrations
+{
+ ///
+ public partial class InitialGovernance : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "governance");
+
+ migrationBuilder.CreateTable(
+ name: "audit_entries",
+ schema: "governance",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ Action = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ EntityType = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ EntityId = table.Column(type: "uuid", nullable: false),
+ ActorMemberId = table.Column(type: "uuid", nullable: true),
+ Details = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true),
+ OccurredAtUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_audit_entries", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_audit_entries_EntityType_EntityId",
+ schema: "governance",
+ table: "audit_entries",
+ columns: new[] { "EntityType", "EntityId" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_audit_entries_OccurredAtUtc",
+ schema: "governance",
+ table: "audit_entries",
+ column: "OccurredAtUtc");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "audit_entries",
+ schema: "governance");
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/GovernanceDbContextModelSnapshot.cs b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/GovernanceDbContextModelSnapshot.cs
new file mode 100644
index 0000000..e3ce041
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/GovernanceDbContextModelSnapshot.cs
@@ -0,0 +1,66 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using TeamUp.Modules.Governance.Persistence;
+
+#nullable disable
+
+namespace TeamUp.Modules.Governance.Persistence.Migrations
+{
+ [DbContext(typeof(GovernanceDbContext))]
+ partial class GovernanceDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("governance")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("TeamUp.Modules.Governance.Domain.AuditEntry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ActorMemberId")
+ .HasColumnType("uuid");
+
+ b.Property("Details")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("EntityId")
+ .HasColumnType("uuid");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("OccurredAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OccurredAtUtc");
+
+ b.HasIndex("EntityType", "EntityId");
+
+ b.ToTable("audit_entries", "governance");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/TeamUp.Modules.Governance.csproj b/src/Modules/TeamUp.Modules.Governance/TeamUp.Modules.Governance.csproj
index 65f5856..58a70e9 100644
--- a/src/Modules/TeamUp.Modules.Governance/TeamUp.Modules.Governance.csproj
+++ b/src/Modules/TeamUp.Modules.Governance/TeamUp.Modules.Governance.csproj
@@ -1,10 +1,15 @@
-
+
+
+
+
+
+
+
diff --git a/src/Modules/TeamUp.Modules.Identity/Access/CurrentUser.cs b/src/Modules/TeamUp.Modules.Identity/Access/CurrentUser.cs
new file mode 100644
index 0000000..ab6be96
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Identity/Access/CurrentUser.cs
@@ -0,0 +1,52 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Http;
+using Microsoft.IdentityModel.JsonWebTokens;
+using TeamUp.Modules.Identity.Auth;
+using TeamUp.SharedKernel.Access;
+
+namespace TeamUp.Modules.Identity.Access;
+
+///
+/// Resolves from the request's JWT claims. JWT bearer is configured with
+/// MapInboundClaims=false, so claim names stay raw ("sub", "email", "membership"). In the worker
+/// (no HttpContext) this reports unauthenticated.
+///
+internal sealed class CurrentUser(IHttpContextAccessor accessor) : ICurrentUser
+{
+ private ClaimsPrincipal? Principal => accessor.HttpContext?.User;
+
+ public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated == true;
+
+ public Guid MemberId =>
+ Guid.TryParse(Principal?.FindFirstValue(JwtRegisteredClaimNames.Sub), out var id)
+ ? id
+ : throw new InvalidOperationException("No authenticated member on the current request.");
+
+ public string Email => Principal?.FindFirstValue(JwtRegisteredClaimNames.Email) ?? string.Empty;
+
+ public IReadOnlyList Memberships
+ {
+ get
+ {
+ if (Principal is null)
+ {
+ return [];
+ }
+
+ var memberships = new List();
+ foreach (var claim in Principal.FindAll(JwtTokenService.MembershipClaim))
+ {
+ var parts = claim.Value.Split(':');
+ if (parts.Length == 3
+ && Enum.TryParse(parts[0], out var scopeType)
+ && Guid.TryParse(parts[1], out var scopeId)
+ && Enum.TryParse(parts[2], out var role))
+ {
+ memberships.Add(new ScopedRole(new ScopeRef(scopeType, scopeId), role));
+ }
+ }
+
+ return memberships;
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Identity/Access/PermissionService.cs b/src/Modules/TeamUp.Modules.Identity/Access/PermissionService.cs
new file mode 100644
index 0000000..c8b7a2a
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Identity/Access/PermissionService.cs
@@ -0,0 +1,29 @@
+using TeamUp.SharedKernel.Access;
+
+namespace TeamUp.Modules.Identity.Access;
+
+///
+/// Default : the current user has a capability if any of their
+/// memberships sits on a scope in the supplied chain and that role permits the capability.
+///
+internal sealed class PermissionService(ICurrentUser currentUser) : IPermissionService
+{
+ public bool Has(Capability capability, params ScopeRef[] scopeChain)
+ {
+ if (!currentUser.IsAuthenticated || scopeChain.Length == 0)
+ {
+ return false;
+ }
+
+ foreach (var membership in currentUser.Memberships)
+ {
+ if (Array.IndexOf(scopeChain, membership.Scope) >= 0
+ && AccessPolicy.Permits(membership.Role, capability))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Identity/Auth/JwtOptions.cs b/src/Modules/TeamUp.Modules.Identity/Auth/JwtOptions.cs
new file mode 100644
index 0000000..549c272
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Identity/Auth/JwtOptions.cs
@@ -0,0 +1,11 @@
+namespace TeamUp.Modules.Identity.Auth;
+
+internal sealed class JwtOptions
+{
+ public const string SectionName = "Jwt";
+
+ public string Secret { get; set; } = string.Empty;
+ public string Issuer { get; set; } = "teamup";
+ public string Audience { get; set; } = "teamup";
+ public int ExpiryMinutes { get; set; } = 480;
+}
diff --git a/src/Modules/TeamUp.Modules.Identity/Auth/JwtTokenService.cs b/src/Modules/TeamUp.Modules.Identity/Auth/JwtTokenService.cs
new file mode 100644
index 0000000..05c5df4
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Identity/Auth/JwtTokenService.cs
@@ -0,0 +1,47 @@
+using System.Security.Claims;
+using System.Text;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Tokens;
+using TeamUp.Modules.Identity.Domain;
+
+namespace TeamUp.Modules.Identity.Auth;
+
+/// Issues signed JWTs carrying the member id, email, and one claim per membership.
+internal sealed class JwtTokenService(IOptions options, TimeProvider timeProvider)
+{
+ public const string MembershipClaim = "membership";
+
+ private readonly JwtOptions _options = options.Value;
+
+ public string Issue(Member member, IReadOnlyList memberships)
+ {
+ var now = timeProvider.GetUtcNow();
+
+ var claims = new List
+ {
+ new(JwtRegisteredClaimNames.Sub, member.Id.ToString()),
+ new(JwtRegisteredClaimNames.Email, member.Email),
+ new("name", member.DisplayName),
+ };
+
+ foreach (var membership in memberships)
+ {
+ claims.Add(new Claim(MembershipClaim, $"{membership.ScopeType}:{membership.ScopeId}:{membership.Role}"));
+ }
+
+ var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret));
+ var descriptor = new SecurityTokenDescriptor
+ {
+ Issuer = _options.Issuer,
+ Audience = _options.Audience,
+ Subject = new ClaimsIdentity(claims),
+ IssuedAt = now.UtcDateTime,
+ NotBefore = now.UtcDateTime,
+ Expires = now.AddMinutes(_options.ExpiryMinutes).UtcDateTime,
+ SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
+ };
+
+ return new JsonWebTokenHandler().CreateToken(descriptor);
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Identity/Contracts/IMemberDirectory.cs b/src/Modules/TeamUp.Modules.Identity/Contracts/IMemberDirectory.cs
new file mode 100644
index 0000000..0d2f3c6
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Identity/Contracts/IMemberDirectory.cs
@@ -0,0 +1,17 @@
+namespace TeamUp.Modules.Identity.Contracts;
+
+/// Public, non-sensitive member info other modules may display (e.g. board assignees).
+public sealed record MemberSummary(Guid Id, string Email, string DisplayName);
+
+///
+/// The Identity module's public surface for resolving member display info by id. Other modules
+/// depend on this interface — never on Identity's entities or DbContext.
+///
+public interface IMemberDirectory
+{
+ Task FindByIdAsync(Guid memberId, CancellationToken cancellationToken = default);
+
+ Task> GetByIdsAsync(
+ IReadOnlyCollection memberIds,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Modules/TeamUp.Modules.Identity/Domain/Invitation.cs b/src/Modules/TeamUp.Modules.Identity/Domain/Invitation.cs
new file mode 100644
index 0000000..d97fa1b
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Identity/Domain/Invitation.cs
@@ -0,0 +1,55 @@
+using TeamUp.SharedKernel.Access;
+using TeamUp.SharedKernel.Domain;
+
+namespace TeamUp.Modules.Identity.Domain;
+
+internal enum InvitationStatus
+{
+ Pending,
+ Accepted,
+ Revoked,
+}
+
+/// An invitation to join at a scope+role. Accepting it creates the member + membership.
+internal sealed class Invitation : Entity
+{
+ public string Email { get; private set; } = null!;
+ public ScopeType ScopeType { get; private set; }
+ public Guid ScopeId { get; private set; }
+ public RoleType Role { get; private set; }
+ public string Token { get; private set; } = null!;
+ public InvitationStatus Status { get; private set; }
+ public Guid InvitedByMemberId { get; private set; }
+ public DateTimeOffset CreatedAtUtc { get; private set; }
+ public DateTimeOffset? AcceptedAtUtc { get; private set; }
+
+ private Invitation()
+ {
+ }
+
+ public Invitation(
+ string email,
+ ScopeRef scope,
+ RoleType role,
+ string token,
+ Guid invitedByMemberId,
+ DateTimeOffset createdAtUtc)
+ {
+ Email = email;
+ ScopeType = scope.Type;
+ ScopeId = scope.Id;
+ Role = role;
+ Token = token;
+ InvitedByMemberId = invitedByMemberId;
+ Status = InvitationStatus.Pending;
+ CreatedAtUtc = createdAtUtc;
+ }
+
+ public ScopeRef Scope => new(ScopeType, ScopeId);
+
+ public void Accept(DateTimeOffset whenUtc)
+ {
+ Status = InvitationStatus.Accepted;
+ AcceptedAtUtc = whenUtc;
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Identity/Domain/Member.cs b/src/Modules/TeamUp.Modules.Identity/Domain/Member.cs
new file mode 100644
index 0000000..fb98992
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Identity/Domain/Member.cs
@@ -0,0 +1,43 @@
+using TeamUp.SharedKernel.Domain;
+
+namespace TeamUp.Modules.Identity.Domain;
+
+internal enum MemberStatus
+{
+ Invited,
+ Active,
+ Disabled,
+}
+
+/// An invited/active human in the system. Identity owns the credential; other modules
+/// reference a member only by id (via the public member directory).
+internal sealed class Member : Entity
+{
+ public string Email { get; private set; } = null!;
+ public string DisplayName { get; private set; } = null!;
+ public string PasswordHash { get; private set; } = null!;
+ public MemberStatus Status { get; private set; }
+ public DateTimeOffset CreatedAtUtc { get; private set; }
+
+ private Member()
+ {
+ }
+
+ public Member(
+ string email,
+ string displayName,
+ string passwordHash,
+ DateTimeOffset createdAtUtc,
+ MemberStatus status = MemberStatus.Active)
+ {
+ Email = email;
+ DisplayName = displayName;
+ PasswordHash = passwordHash;
+ CreatedAtUtc = createdAtUtc;
+ Status = status;
+ }
+
+ public void SetPasswordHash(string passwordHash) => PasswordHash = passwordHash;
+
+ public void Activate() => Status = MemberStatus.Active;
+}
diff --git a/src/Modules/TeamUp.Modules.Identity/Domain/Membership.cs b/src/Modules/TeamUp.Modules.Identity/Domain/Membership.cs
new file mode 100644
index 0000000..c70f3de
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Identity/Domain/Membership.cs
@@ -0,0 +1,29 @@
+using TeamUp.SharedKernel.Access;
+using TeamUp.SharedKernel.Domain;
+
+namespace TeamUp.Modules.Identity.Domain;
+
+/// A member's role at a scope. Additive — a member may hold several.
+internal sealed class Membership : Entity
+{
+ public Guid MemberId { get; private set; }
+ public ScopeType ScopeType { get; private set; }
+ public Guid ScopeId { get; private set; }
+ public RoleType Role { get; private set; }
+ public DateTimeOffset CreatedAtUtc { get; private set; }
+
+ private Membership()
+ {
+ }
+
+ public Membership(Guid memberId, ScopeRef scope, RoleType role, DateTimeOffset createdAtUtc)
+ {
+ MemberId = memberId;
+ ScopeType = scope.Type;
+ ScopeId = scope.Id;
+ Role = role;
+ CreatedAtUtc = createdAtUtc;
+ }
+
+ public ScopeRef Scope => new(ScopeType, ScopeId);
+}
diff --git a/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityDtos.cs b/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityDtos.cs
new file mode 100644
index 0000000..a4075ff
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityDtos.cs
@@ -0,0 +1,34 @@
+using TeamUp.SharedKernel.Access;
+
+namespace TeamUp.Modules.Identity.Endpoints;
+
+internal sealed record BootstrapRequest(
+ string OrganizationName,
+ string OwnerEmail,
+ string OwnerDisplayName,
+ string OwnerPassword);
+
+internal sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
+
+internal sealed record LoginRequest(string Email, string Password);
+
+internal sealed record AuthResponse(string Token, Guid MemberId);
+
+internal sealed record MembershipDto(string ScopeType, Guid ScopeId, string Role);
+
+internal sealed record MeResponse(
+ Guid MemberId,
+ string Email,
+ string DisplayName,
+ IReadOnlyList Memberships);
+
+internal sealed record InviteRequest(
+ string Email,
+ ScopeType ScopeType,
+ Guid ScopeId,
+ RoleType Role,
+ Guid OrganizationId);
+
+internal sealed record InviteResponse(Guid InvitationId, string Token);
+
+internal sealed record AcceptInviteRequest(string Token, string DisplayName, string Password);
diff --git a/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityEndpoints.cs b/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityEndpoints.cs
new file mode 100644
index 0000000..32639f9
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityEndpoints.cs
@@ -0,0 +1,171 @@
+using System.Security.Cryptography;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.EntityFrameworkCore;
+using TeamUp.Modules.Identity.Auth;
+using TeamUp.Modules.Identity.Domain;
+using TeamUp.Modules.Identity.Persistence;
+using TeamUp.SharedKernel.Access;
+using TeamUp.SharedKernel.Auditing;
+using TeamUp.SharedKernel.Modularity;
+
+namespace TeamUp.Modules.Identity.Endpoints;
+
+internal static class IdentityEndpoints
+{
+ public static void Map(IEndpointRouteBuilder endpoints)
+ {
+ var group = endpoints.MapGroup("/api/identity").WithTags("Identity");
+
+ group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("identity")));
+ group.MapPost("/bootstrap", Bootstrap).AllowAnonymous();
+ group.MapPost("/auth/login", Login).AllowAnonymous();
+ group.MapGet("/me", Me).RequireAuthorization();
+ group.MapPost("/invitations", CreateInvitation).RequireAuthorization();
+ group.MapPost("/invitations/accept", AcceptInvitation).AllowAnonymous();
+ }
+
+ private static async Task Bootstrap(
+ BootstrapRequest request,
+ IdentityDbContext db,
+ IPasswordHasher hasher,
+ JwtTokenService tokens,
+ TimeProvider clock,
+ CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(request.OwnerEmail) || string.IsNullOrWhiteSpace(request.OwnerPassword))
+ {
+ return Results.BadRequest("Owner email and password are required.");
+ }
+
+ if (await db.Members.AnyAsync(ct))
+ {
+ return Results.Conflict("The system is already bootstrapped.");
+ }
+
+ var now = clock.GetUtcNow();
+ var organizationId = Guid.CreateVersion7();
+ var owner = new Member(request.OwnerEmail.Trim(), request.OwnerDisplayName.Trim(), string.Empty, now);
+ owner.SetPasswordHash(hasher.HashPassword(owner, request.OwnerPassword));
+
+ var membership = new Membership(owner.Id, ScopeRef.Org(organizationId), RoleType.Owner, now);
+
+ db.Members.Add(owner);
+ db.Memberships.Add(membership);
+ await db.SaveChangesAsync(ct);
+
+ var token = tokens.Issue(owner, [membership]);
+ return Results.Ok(new BootstrapResponse(token, owner.Id, organizationId));
+ }
+
+ private static async Task Login(
+ LoginRequest request,
+ IdentityDbContext db,
+ IPasswordHasher hasher,
+ JwtTokenService tokens,
+ CancellationToken ct)
+ {
+ var member = await db.Members.FirstOrDefaultAsync(m => m.Email == request.Email, ct);
+ if (member is null || member.Status == MemberStatus.Disabled)
+ {
+ return Results.Unauthorized();
+ }
+
+ var result = hasher.VerifyHashedPassword(member, member.PasswordHash, request.Password);
+ if (result == PasswordVerificationResult.Failed)
+ {
+ return Results.Unauthorized();
+ }
+
+ var memberships = await db.Memberships.Where(m => m.MemberId == member.Id).ToListAsync(ct);
+ return Results.Ok(new AuthResponse(tokens.Issue(member, memberships), member.Id));
+ }
+
+ private static async Task Me(ICurrentUser currentUser, IdentityDbContext db, CancellationToken ct)
+ {
+ var member = await db.Members.FirstOrDefaultAsync(m => m.Id == currentUser.MemberId, ct);
+ if (member is null)
+ {
+ return Results.NotFound();
+ }
+
+ var memberships = currentUser.Memberships
+ .Select(m => new MembershipDto(m.Scope.Type.ToString(), m.Scope.Id, m.Role.ToString()))
+ .ToList();
+
+ return Results.Ok(new MeResponse(member.Id, member.Email, member.DisplayName, memberships));
+ }
+
+ private static async Task CreateInvitation(
+ InviteRequest request,
+ ICurrentUser currentUser,
+ IPermissionService permissions,
+ IAuditLog audit,
+ IdentityDbContext db,
+ TimeProvider clock,
+ CancellationToken ct)
+ {
+ var targetScope = new ScopeRef(request.ScopeType, request.ScopeId);
+ var orgScope = ScopeRef.Org(request.OrganizationId);
+ var chain = targetScope == orgScope
+ ? new[] { targetScope }
+ : new[] { targetScope, orgScope };
+
+ if (!permissions.Has(Capability.InvitePeople, chain))
+ {
+ return Results.Forbid();
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Email))
+ {
+ return Results.BadRequest("Email is required.");
+ }
+
+ var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
+ var invitation = new Invitation(
+ request.Email.Trim(), targetScope, request.Role, token, currentUser.MemberId, clock.GetUtcNow());
+
+ db.Invitations.Add(invitation);
+ await db.SaveChangesAsync(ct);
+ await audit.WriteAsync(
+ new AuditEvent("invitation.created", "Invitation", invitation.Id, currentUser.MemberId, request.Email.Trim()), ct);
+
+ return Results.Ok(new InviteResponse(invitation.Id, token));
+ }
+
+ private static async Task AcceptInvitation(
+ AcceptInviteRequest request,
+ IdentityDbContext db,
+ IPasswordHasher hasher,
+ JwtTokenService tokens,
+ IAuditLog audit,
+ TimeProvider clock,
+ CancellationToken ct)
+ {
+ var invitation = await db.Invitations.FirstOrDefaultAsync(i => i.Token == request.Token, ct);
+ if (invitation is null || invitation.Status != InvitationStatus.Pending)
+ {
+ return Results.BadRequest("Invitation not found or already used.");
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Password) || string.IsNullOrWhiteSpace(request.DisplayName))
+ {
+ return Results.BadRequest("Display name and password are required.");
+ }
+
+ var now = clock.GetUtcNow();
+ var member = new Member(invitation.Email, request.DisplayName.Trim(), string.Empty, now);
+ member.SetPasswordHash(hasher.HashPassword(member, request.Password));
+ var membership = new Membership(member.Id, invitation.Scope, invitation.Role, now);
+ invitation.Accept(now);
+
+ db.Members.Add(member);
+ db.Memberships.Add(membership);
+ await db.SaveChangesAsync(ct);
+ await audit.WriteAsync(new AuditEvent("member.joined", "Member", member.Id, member.Id, member.Email), ct);
+
+ return Results.Ok(new AuthResponse(tokens.Issue(member, [membership]), member.Id));
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Identity/IdentityModule.cs b/src/Modules/TeamUp.Modules.Identity/IdentityModule.cs
index 3c7c08c..3f098b0 100644
--- a/src/Modules/TeamUp.Modules.Identity/IdentityModule.cs
+++ b/src/Modules/TeamUp.Modules.Identity/IdentityModule.cs
@@ -1,27 +1,65 @@
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Http;
+using System.Text;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Routing;
+using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.IdentityModel.Tokens;
+using TeamUp.Modules.Identity.Access;
+using TeamUp.Modules.Identity.Auth;
+using TeamUp.Modules.Identity.Contracts;
+using TeamUp.Modules.Identity.Domain;
+using TeamUp.Modules.Identity.Endpoints;
+using TeamUp.Modules.Identity.Persistence;
+using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Modularity;
+using TeamUp.SharedKernel.Persistence;
namespace TeamUp.Modules.Identity;
-/// Identity & access: members, memberships, roles, permission enforcement (M1).
+/// Identity & access: members, memberships, invitations, JWT auth, permission enforcement (M1).
public sealed class IdentityModule : IModule
{
public string Name => "identity";
public void Register(IServiceCollection services, IConfiguration configuration)
{
- // Skeleton: no services yet. M1 introduces this module's (internal) DbContext,
- // FluentValidation validators, and domain services here.
+ var connectionString = configuration.GetConnectionString("Postgres")
+ ?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'.");
+
+ services.AddDbContext(options => options.UseNpgsql(connectionString));
+ services.AddScoped(sp => sp.GetRequiredService());
+
+ services.TryAddSingleton(TimeProvider.System);
+ services.AddHttpContextAccessor();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddSingleton, PasswordHasher>();
+
+ services.Configure(configuration.GetSection(JwtOptions.SectionName));
+ var jwt = configuration.GetSection(JwtOptions.SectionName).Get() ?? new JwtOptions();
+
+ services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(options =>
+ {
+ options.MapInboundClaims = false;
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidIssuer = jwt.Issuer,
+ ValidateAudience = true,
+ ValidAudience = jwt.Audience,
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Secret)),
+ ValidateLifetime = true,
+ };
+ });
+ services.AddAuthorization();
}
- public void MapEndpoints(IEndpointRouteBuilder endpoints)
- {
- endpoints.MapGroup($"/api/{Name}")
- .WithTags("Identity")
- .MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name)));
- }
+ public void MapEndpoints(IEndpointRouteBuilder endpoints) => IdentityEndpoints.Map(endpoints);
}
diff --git a/src/Modules/TeamUp.Modules.Identity/Persistence/IdentityDbContext.cs b/src/Modules/TeamUp.Modules.Identity/Persistence/IdentityDbContext.cs
new file mode 100644
index 0000000..53664fc
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Identity/Persistence/IdentityDbContext.cs
@@ -0,0 +1,54 @@
+using Microsoft.EntityFrameworkCore;
+using TeamUp.Modules.Identity.Domain;
+using TeamUp.SharedKernel.Persistence;
+
+namespace TeamUp.Modules.Identity.Persistence;
+
+internal sealed class IdentityDbContext(DbContextOptions options)
+ : DbContext(options), IModuleDbContext
+{
+ public DbSet Members => Set();
+ public DbSet Memberships => Set();
+ public DbSet Invitations => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasDefaultSchema("identity");
+
+ modelBuilder.Entity(member =>
+ {
+ member.ToTable("members");
+ member.HasKey(m => m.Id);
+ member.Property(m => m.Email).HasMaxLength(256).IsRequired();
+ member.HasIndex(m => m.Email).IsUnique();
+ member.Property(m => m.DisplayName).HasMaxLength(128).IsRequired();
+ member.Property(m => m.PasswordHash).HasMaxLength(512).IsRequired();
+ member.Property(m => m.Status).HasConversion().HasMaxLength(32);
+ });
+
+ modelBuilder.Entity(membership =>
+ {
+ membership.ToTable("memberships");
+ membership.HasKey(m => m.Id);
+ membership.Property(m => m.ScopeType).HasConversion().HasMaxLength(32);
+ membership.Property(m => m.Role).HasConversion().HasMaxLength(32);
+ membership.Ignore(m => m.Scope);
+ membership.HasIndex(m => m.MemberId);
+ membership.HasIndex(m => new { m.MemberId, m.ScopeType, m.ScopeId, m.Role }).IsUnique();
+ });
+
+ modelBuilder.Entity(invitation =>
+ {
+ invitation.ToTable("invitations");
+ invitation.HasKey(i => i.Id);
+ invitation.Property(i => i.Email).HasMaxLength(256).IsRequired();
+ invitation.Property(i => i.Token).HasMaxLength(128).IsRequired();
+ invitation.HasIndex(i => i.Token).IsUnique();
+ invitation.HasIndex(i => i.Email);
+ invitation.Property(i => i.ScopeType).HasConversion().HasMaxLength(32);
+ invitation.Property(i => i.Role).HasConversion().HasMaxLength(32);
+ invitation.Property(i => i.Status).HasConversion().HasMaxLength(32);
+ invitation.Ignore(i => i.Scope);
+ });
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Identity/Persistence/IdentityDbContextFactory.cs b/src/Modules/TeamUp.Modules.Identity/Persistence/IdentityDbContextFactory.cs
new file mode 100644
index 0000000..f3367b5
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Identity/Persistence/IdentityDbContextFactory.cs
@@ -0,0 +1,21 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace TeamUp.Modules.Identity.Persistence;
+
+/// Design-time factory so `dotnet ef` can build the internal context without a host.
+internal sealed class IdentityDbContextFactory : IDesignTimeDbContextFactory
+{
+ public IdentityDbContext CreateDbContext(string[] args)
+ {
+ var connectionString =
+ Environment.GetEnvironmentVariable("ConnectionStrings__Postgres")
+ ?? "Host=localhost;Port=5432;Database=teamup;Username=teamup;Password=teamup";
+
+ var options = new DbContextOptionsBuilder()
+ .UseNpgsql(connectionString)
+ .Options;
+
+ return new IdentityDbContext(options);
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Identity/Persistence/MemberDirectory.cs b/src/Modules/TeamUp.Modules.Identity/Persistence/MemberDirectory.cs
new file mode 100644
index 0000000..7a588a7
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Identity/Persistence/MemberDirectory.cs
@@ -0,0 +1,24 @@
+using Microsoft.EntityFrameworkCore;
+using TeamUp.Modules.Identity.Contracts;
+
+namespace TeamUp.Modules.Identity.Persistence;
+
+internal sealed class MemberDirectory(IdentityDbContext db) : IMemberDirectory
+{
+ public async Task FindByIdAsync(Guid memberId, CancellationToken cancellationToken = default) =>
+ await db.Members
+ .Where(m => m.Id == memberId)
+ .Select(m => new MemberSummary(m.Id, m.Email, m.DisplayName))
+ .FirstOrDefaultAsync(cancellationToken);
+
+ public async Task> GetByIdsAsync(
+ IReadOnlyCollection memberIds,
+ CancellationToken cancellationToken = default)
+ {
+ var ids = memberIds.ToHashSet();
+ return await db.Members
+ .Where(m => ids.Contains(m.Id))
+ .Select(m => new MemberSummary(m.Id, m.Email, m.DisplayName))
+ .ToDictionaryAsync(m => m.Id, cancellationToken);
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Identity/Persistence/Migrations/20260609042521_InitialIdentity.Designer.cs b/src/Modules/TeamUp.Modules.Identity/Persistence/Migrations/20260609042521_InitialIdentity.Designer.cs
new file mode 100644
index 0000000..a3d3666
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Identity/Persistence/Migrations/20260609042521_InitialIdentity.Designer.cs
@@ -0,0 +1,156 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using TeamUp.Modules.Identity.Persistence;
+
+#nullable disable
+
+namespace TeamUp.Modules.Identity.Persistence.Migrations
+{
+ [DbContext(typeof(IdentityDbContext))]
+ [Migration("20260609042521_InitialIdentity")]
+ partial class InitialIdentity
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("identity")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("TeamUp.Modules.Identity.Domain.Invitation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AcceptedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("InvitedByMemberId")
+ .HasColumnType("uuid");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("ScopeId")
+ .HasColumnType("uuid");
+
+ b.Property("ScopeType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email");
+
+ b.HasIndex("Token")
+ .IsUnique();
+
+ b.ToTable("invitations", "identity");
+ });
+
+ modelBuilder.Entity("TeamUp.Modules.Identity.Domain.Member", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.ToTable("members", "identity");
+ });
+
+ modelBuilder.Entity("TeamUp.Modules.Identity.Domain.Membership", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MemberId")
+ .HasColumnType("uuid");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("ScopeId")
+ .HasColumnType("uuid");
+
+ b.Property("ScopeType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MemberId");
+
+ b.HasIndex("MemberId", "ScopeType", "ScopeId", "Role")
+ .IsUnique();
+
+ b.ToTable("memberships", "identity");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Identity/Persistence/Migrations/20260609042521_InitialIdentity.cs b/src/Modules/TeamUp.Modules.Identity/Persistence/Migrations/20260609042521_InitialIdentity.cs
new file mode 100644
index 0000000..f35c495
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Identity/Persistence/Migrations/20260609042521_InitialIdentity.cs
@@ -0,0 +1,122 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace TeamUp.Modules.Identity.Persistence.Migrations
+{
+ ///
+ public partial class InitialIdentity : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "identity");
+
+ migrationBuilder.CreateTable(
+ name: "invitations",
+ schema: "identity",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: false),
+ ScopeType = table.Column(type: "character varying(32)", maxLength: 32, nullable: false),
+ ScopeId = table.Column(type: "uuid", nullable: false),
+ Role = table.Column(type: "character varying(32)", maxLength: 32, nullable: false),
+ Token = table.Column(type: "character varying(128)", maxLength: 128, nullable: false),
+ Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false),
+ InvitedByMemberId = table.Column(type: "uuid", nullable: false),
+ CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ AcceptedAtUtc = table.Column(type: "timestamp with time zone", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_invitations", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "members",
+ schema: "identity",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: false),
+ DisplayName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false),
+ PasswordHash = table.Column(type: "character varying(512)", maxLength: 512, nullable: false),
+ Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false),
+ CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_members", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "memberships",
+ schema: "identity",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ MemberId = table.Column(type: "uuid", nullable: false),
+ ScopeType = table.Column(type: "character varying(32)", maxLength: 32, nullable: false),
+ ScopeId = table.Column(type: "uuid", nullable: false),
+ Role = table.Column(type: "character varying(32)", maxLength: 32, nullable: false),
+ CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_memberships", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_invitations_Email",
+ schema: "identity",
+ table: "invitations",
+ column: "Email");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_invitations_Token",
+ schema: "identity",
+ table: "invitations",
+ column: "Token",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_members_Email",
+ schema: "identity",
+ table: "members",
+ column: "Email",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_memberships_MemberId",
+ schema: "identity",
+ table: "memberships",
+ column: "MemberId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_memberships_MemberId_ScopeType_ScopeId_Role",
+ schema: "identity",
+ table: "memberships",
+ columns: new[] { "MemberId", "ScopeType", "ScopeId", "Role" },
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "invitations",
+ schema: "identity");
+
+ migrationBuilder.DropTable(
+ name: "members",
+ schema: "identity");
+
+ migrationBuilder.DropTable(
+ name: "memberships",
+ schema: "identity");
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Identity/Persistence/Migrations/IdentityDbContextModelSnapshot.cs b/src/Modules/TeamUp.Modules.Identity/Persistence/Migrations/IdentityDbContextModelSnapshot.cs
new file mode 100644
index 0000000..3afa602
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Identity/Persistence/Migrations/IdentityDbContextModelSnapshot.cs
@@ -0,0 +1,153 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using TeamUp.Modules.Identity.Persistence;
+
+#nullable disable
+
+namespace TeamUp.Modules.Identity.Persistence.Migrations
+{
+ [DbContext(typeof(IdentityDbContext))]
+ partial class IdentityDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("identity")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("TeamUp.Modules.Identity.Domain.Invitation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AcceptedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("InvitedByMemberId")
+ .HasColumnType("uuid");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("ScopeId")
+ .HasColumnType("uuid");
+
+ b.Property("ScopeType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email");
+
+ b.HasIndex("Token")
+ .IsUnique();
+
+ b.ToTable("invitations", "identity");
+ });
+
+ modelBuilder.Entity("TeamUp.Modules.Identity.Domain.Member", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.ToTable("members", "identity");
+ });
+
+ modelBuilder.Entity("TeamUp.Modules.Identity.Domain.Membership", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MemberId")
+ .HasColumnType("uuid");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("ScopeId")
+ .HasColumnType("uuid");
+
+ b.Property("ScopeType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MemberId");
+
+ b.HasIndex("MemberId", "ScopeType", "ScopeId", "Role")
+ .IsUnique();
+
+ b.ToTable("memberships", "identity");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Identity/TeamUp.Modules.Identity.csproj b/src/Modules/TeamUp.Modules.Identity/TeamUp.Modules.Identity.csproj
index 65f5856..d33fd0d 100644
--- a/src/Modules/TeamUp.Modules.Identity/TeamUp.Modules.Identity.csproj
+++ b/src/Modules/TeamUp.Modules.Identity/TeamUp.Modules.Identity.csproj
@@ -1,10 +1,20 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Enums.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Enums.cs
new file mode 100644
index 0000000..a2c5971
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Enums.cs
@@ -0,0 +1,34 @@
+namespace TeamUp.Modules.OrgBoard.Domain;
+
+/// The seat-state triad — the load-bearing concept of the UI (human / open / AI).
+internal enum SeatState
+{
+ Human,
+ Open,
+ Ai,
+}
+
+internal enum WorkItemType
+{
+ Spec,
+ Story,
+ Test,
+ Review,
+ Release,
+}
+
+/// The board columns: backlog → in progress → in review → done.
+internal enum WorkItemStatus
+{
+ Backlog,
+ InProgress,
+ InReview,
+ Done,
+}
+
+internal enum AssigneeKind
+{
+ Unassigned,
+ Member,
+ Agent,
+}
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Organization.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Organization.cs
new file mode 100644
index 0000000..6db0d2b
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Organization.cs
@@ -0,0 +1,23 @@
+using TeamUp.SharedKernel.Domain;
+
+namespace TeamUp.Modules.OrgBoard.Domain;
+
+/// The company. Its id is the Organization scope that org-level memberships are granted at.
+internal sealed class Organization : Entity
+{
+ public string Name { get; private set; } = null!;
+ public DateTimeOffset CreatedAtUtc { get; private set; }
+
+ private Organization()
+ {
+ }
+
+ public Organization(Guid id, string name, DateTimeOffset createdAtUtc)
+ {
+ Id = id;
+ Name = name;
+ CreatedAtUtc = createdAtUtc;
+ }
+
+ public void Rename(string name) => Name = name;
+}
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Seat.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Seat.cs
new file mode 100644
index 0000000..6ddd707
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Seat.cs
@@ -0,0 +1,41 @@
+using TeamUp.SharedKernel.Domain;
+
+namespace TeamUp.Modules.OrgBoard.Domain;
+
+/// A role on a team, in one of three states: human / open / AI. AI seats are configured in M3.
+internal sealed class Seat : Entity
+{
+ public Guid TeamId { get; private set; }
+ public string RoleName { get; private set; } = null!;
+ public SeatState State { get; private set; }
+ public Guid? MemberId { get; private set; }
+ public Guid? AgentId { get; private set; }
+ public DateTimeOffset CreatedAtUtc { get; private set; }
+
+ private Seat()
+ {
+ }
+
+ public Seat(Guid teamId, string roleName, SeatState state, DateTimeOffset createdAtUtc, Guid? memberId = null)
+ {
+ TeamId = teamId;
+ RoleName = roleName;
+ State = state;
+ MemberId = memberId;
+ CreatedAtUtc = createdAtUtc;
+ }
+
+ public void AssignMember(Guid memberId)
+ {
+ MemberId = memberId;
+ AgentId = null;
+ State = SeatState.Human;
+ }
+
+ public void Open()
+ {
+ MemberId = null;
+ AgentId = null;
+ State = SeatState.Open;
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Team.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Team.cs
new file mode 100644
index 0000000..903df10
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Team.cs
@@ -0,0 +1,22 @@
+using TeamUp.SharedKernel.Domain;
+
+namespace TeamUp.Modules.OrgBoard.Domain;
+
+/// A team within an organization. Team-level memberships are granted at its id (Team scope).
+internal sealed class Team : Entity
+{
+ public Guid OrganizationId { get; private set; }
+ public string Name { get; private set; } = null!;
+ public DateTimeOffset CreatedAtUtc { get; private set; }
+
+ private Team()
+ {
+ }
+
+ public Team(Guid organizationId, string name, DateTimeOffset createdAtUtc)
+ {
+ OrganizationId = organizationId;
+ Name = name;
+ CreatedAtUtc = createdAtUtc;
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/WorkItem.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/WorkItem.cs
new file mode 100644
index 0000000..31e793b
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/WorkItem.cs
@@ -0,0 +1,64 @@
+using TeamUp.SharedKernel.Domain;
+
+namespace TeamUp.Modules.OrgBoard.Domain;
+
+/// A board task. Humans and AI share this one model — the assignee is a member or an agent.
+internal sealed class WorkItem : Entity
+{
+ public Guid TeamId { get; private set; }
+ public string Title { get; private set; } = null!;
+ public string? Description { get; private set; }
+ public WorkItemType Type { get; private set; }
+ public WorkItemStatus Status { get; private set; }
+ public AssigneeKind AssigneeKind { get; private set; }
+ public Guid? AssigneeId { get; private set; }
+ public Guid? ParentId { get; private set; }
+ public Guid CreatedByMemberId { get; private set; }
+ public DateTimeOffset CreatedAtUtc { get; private set; }
+ public DateTimeOffset UpdatedAtUtc { get; private set; }
+
+ private WorkItem()
+ {
+ }
+
+ public WorkItem(
+ Guid teamId,
+ string title,
+ string? description,
+ WorkItemType type,
+ Guid createdByMemberId,
+ DateTimeOffset nowUtc,
+ Guid? parentId = null)
+ {
+ TeamId = teamId;
+ Title = title;
+ Description = description;
+ Type = type;
+ Status = WorkItemStatus.Backlog;
+ AssigneeKind = AssigneeKind.Unassigned;
+ CreatedByMemberId = createdByMemberId;
+ ParentId = parentId;
+ CreatedAtUtc = nowUtc;
+ UpdatedAtUtc = nowUtc;
+ }
+
+ public void MoveTo(WorkItemStatus status, DateTimeOffset nowUtc)
+ {
+ Status = status;
+ UpdatedAtUtc = nowUtc;
+ }
+
+ public void AssignToMember(Guid memberId, DateTimeOffset nowUtc)
+ {
+ AssigneeKind = AssigneeKind.Member;
+ AssigneeId = memberId;
+ UpdatedAtUtc = nowUtc;
+ }
+
+ public void Unassign(DateTimeOffset nowUtc)
+ {
+ AssigneeKind = AssigneeKind.Unassigned;
+ AssigneeId = null;
+ UpdatedAtUtc = nowUtc;
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs
new file mode 100644
index 0000000..77933a1
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs
@@ -0,0 +1,32 @@
+using TeamUp.Modules.OrgBoard.Domain;
+
+namespace TeamUp.Modules.OrgBoard.Endpoints;
+
+internal sealed record CreateOrganizationRequest(Guid OrganizationId, string Name);
+
+internal sealed record OrganizationResponse(Guid Id, string Name);
+
+internal sealed record CreateTeamRequest(Guid OrganizationId, string Name);
+
+internal sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name);
+
+internal sealed record CreateTaskRequest(Guid TeamId, string Title, string? Description, WorkItemType Type);
+
+internal sealed record MoveTaskRequest(WorkItemStatus Status);
+
+internal sealed record AssignTaskRequest(Guid MemberId);
+
+internal sealed record TaskResponse(
+ Guid Id,
+ Guid TeamId,
+ string Title,
+ string? Description,
+ string Type,
+ string Status,
+ string AssigneeKind,
+ Guid? AssigneeId,
+ Guid? ParentId);
+
+internal sealed record BoardColumn(string Status, IReadOnlyList Items);
+
+internal sealed record BoardResponse(Guid TeamId, IReadOnlyList Columns);
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs
new file mode 100644
index 0000000..aa46d03
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs
@@ -0,0 +1,228 @@
+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;
+using TeamUp.SharedKernel.Modularity;
+
+namespace TeamUp.Modules.OrgBoard.Endpoints;
+
+internal static class OrgBoardEndpoints
+{
+ public static void Map(IEndpointRouteBuilder endpoints)
+ {
+ var group = endpoints.MapGroup("/api/orgboard").WithTags("OrgBoard");
+
+ group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("orgboard")));
+ group.MapPost("/organizations", CreateOrganization).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.MapGet("/cartable", Cartable).RequireAuthorization();
+ }
+
+ private static TaskResponse ToResponse(WorkItem item) => new(
+ item.Id, item.TeamId, item.Title, item.Description,
+ item.Type.ToString(), item.Status.ToString(), item.AssigneeKind.ToString(),
+ item.AssigneeId, item.ParentId);
+
+ private static async Task CreateOrganization(
+ CreateOrganizationRequest request, IPermissionService permissions,
+ OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
+ {
+ if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId)))
+ {
+ return Results.Forbid();
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Name))
+ {
+ return Results.BadRequest("Name is required.");
+ }
+
+ var organization = await db.Organizations.FirstOrDefaultAsync(o => o.Id == request.OrganizationId, ct);
+ if (organization is null)
+ {
+ organization = new Organization(request.OrganizationId, request.Name.Trim(), clock.GetUtcNow());
+ db.Organizations.Add(organization);
+ }
+ else
+ {
+ organization.Rename(request.Name.Trim());
+ }
+
+ await db.SaveChangesAsync(ct);
+ return Results.Ok(new OrganizationResponse(organization.Id, organization.Name));
+ }
+
+ private static async Task CreateTeam(
+ CreateTeamRequest request, ICurrentUser user, IPermissionService permissions,
+ IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
+ {
+ if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId)))
+ {
+ return Results.Forbid();
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Name))
+ {
+ return Results.BadRequest("Name is required.");
+ }
+
+ if (!await db.Organizations.AnyAsync(o => o.Id == request.OrganizationId, ct))
+ {
+ return Results.BadRequest("Organization does not exist; create it first.");
+ }
+
+ var team = new Team(request.OrganizationId, request.Name.Trim(), clock.GetUtcNow());
+ db.Teams.Add(team);
+ await db.SaveChangesAsync(ct);
+ await audit.WriteAsync(new AuditEvent("team.created", "Team", team.Id, user.MemberId, team.Name), ct);
+ return Results.Ok(new TeamResponse(team.Id, team.OrganizationId, team.Name));
+ }
+
+ private static async Task ListTeams(
+ Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
+ {
+ if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(organizationId)))
+ {
+ return Results.Forbid();
+ }
+
+ var teams = await db.Teams
+ .Where(t => t.OrganizationId == organizationId)
+ .OrderBy(t => t.CreatedAtUtc)
+ .Select(t => new TeamResponse(t.Id, t.OrganizationId, t.Name))
+ .ToListAsync(ct);
+
+ return Results.Ok(teams);
+ }
+
+ private static async Task CreateTask(
+ CreateTaskRequest request, ICurrentUser user, IPermissionService permissions,
+ IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
+ {
+ var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == request.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();
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Title))
+ {
+ return Results.BadRequest("Title is required.");
+ }
+
+ var item = new WorkItem(team.Id, request.Title.Trim(), request.Description, request.Type, user.MemberId, clock.GetUtcNow());
+ db.WorkItems.Add(item);
+ await db.SaveChangesAsync(ct);
+ await audit.WriteAsync(new AuditEvent("task.created", "WorkItem", item.Id, user.MemberId, item.Title), ct);
+ return Results.Ok(ToResponse(item));
+ }
+
+ private static async Task GetBoard(
+ Guid teamId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
+ {
+ var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == teamId, ct);
+ if (team is null)
+ {
+ return Results.NotFound("Team not found.");
+ }
+
+ if (!permissions.Has(Capability.ViewBoard, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
+ {
+ return Results.Forbid();
+ }
+
+ var items = await db.WorkItems.Where(w => w.TeamId == teamId).OrderBy(w => w.CreatedAtUtc).ToListAsync(ct);
+ var columns = Enum.GetValues()
+ .Select(status => new BoardColumn(
+ status.ToString(),
+ items.Where(i => i.Status == status).Select(ToResponse).ToList()))
+ .ToList();
+
+ return Results.Ok(new BoardResponse(teamId, columns));
+ }
+
+ private static async Task MoveTask(
+ Guid id, MoveTaskRequest request, ICurrentUser user, IPermissionService permissions,
+ IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
+ {
+ var (item, team, error) = await LoadItemWithTeam(db, id, ct);
+ if (error is not null)
+ {
+ return error;
+ }
+
+ if (!permissions.Has(Capability.WorkTasks, ScopeRef.Team(team!.Id), ScopeRef.Org(team.OrganizationId)))
+ {
+ return Results.Forbid();
+ }
+
+ item!.MoveTo(request.Status, clock.GetUtcNow());
+ await db.SaveChangesAsync(ct);
+ await audit.WriteAsync(new AuditEvent("task.moved", "WorkItem", item.Id, user.MemberId, request.Status.ToString()), ct);
+ return Results.Ok(ToResponse(item));
+ }
+
+ private static async Task AssignTask(
+ Guid id, AssignTaskRequest request, ICurrentUser user, IPermissionService permissions,
+ IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
+ {
+ var (item, team, error) = await LoadItemWithTeam(db, id, ct);
+ if (error is not null)
+ {
+ return error;
+ }
+
+ if (!permissions.Has(Capability.WorkTasks, ScopeRef.Team(team!.Id), ScopeRef.Org(team.OrganizationId)))
+ {
+ return Results.Forbid();
+ }
+
+ item!.AssignToMember(request.MemberId, clock.GetUtcNow());
+ await db.SaveChangesAsync(ct);
+ await audit.WriteAsync(new AuditEvent("task.assigned", "WorkItem", item.Id, user.MemberId, request.MemberId.ToString()), ct);
+ return Results.Ok(ToResponse(item));
+ }
+
+ private static async Task Cartable(ICurrentUser user, OrgBoardDbContext db, CancellationToken ct)
+ {
+ var memberId = user.MemberId;
+ var items = await db.WorkItems
+ .Where(w => w.AssigneeKind == AssigneeKind.Member && w.AssigneeId == memberId)
+ .OrderByDescending(w => w.UpdatedAtUtc)
+ .ToListAsync(ct);
+
+ return Results.Ok(items.Select(ToResponse).ToList());
+ }
+
+ private static async Task<(WorkItem? Item, Team? Team, IResult? Error)> LoadItemWithTeam(
+ OrgBoardDbContext db, Guid itemId, CancellationToken ct)
+ {
+ var item = await db.WorkItems.FirstOrDefaultAsync(w => w.Id == itemId, ct);
+ if (item is null)
+ {
+ return (null, null, Results.NotFound("Task not found."));
+ }
+
+ var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == item.TeamId, ct);
+ if (team is null)
+ {
+ return (null, null, Results.NotFound("Team not found."));
+ }
+
+ return (item, team, null);
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/OrgBoardModule.cs b/src/Modules/TeamUp.Modules.OrgBoard/OrgBoardModule.cs
index 3ee2015..2d62eba 100644
--- a/src/Modules/TeamUp.Modules.OrgBoard/OrgBoardModule.cs
+++ b/src/Modules/TeamUp.Modules.OrgBoard/OrgBoardModule.cs
@@ -1,9 +1,12 @@
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
+using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using TeamUp.Modules.OrgBoard.Endpoints;
+using TeamUp.Modules.OrgBoard.Persistence;
using TeamUp.SharedKernel.Modularity;
+using TeamUp.SharedKernel.Persistence;
namespace TeamUp.Modules.OrgBoard;
@@ -14,14 +17,13 @@ public sealed class OrgBoardModule : IModule
public void Register(IServiceCollection services, IConfiguration configuration)
{
- // Skeleton: no services yet. M1 introduces this module's (internal) DbContext,
- // FluentValidation validators, and domain services here.
+ var connectionString = configuration.GetConnectionString("Postgres")
+ ?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'.");
+
+ services.AddDbContext(options => options.UseNpgsql(connectionString));
+ services.AddScoped(sp => sp.GetRequiredService());
+ services.TryAddSingleton(TimeProvider.System);
}
- public void MapEndpoints(IEndpointRouteBuilder endpoints)
- {
- endpoints.MapGroup($"/api/{Name}")
- .WithTags("OrgBoard")
- .MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name)));
- }
+ public void MapEndpoints(IEndpointRouteBuilder endpoints) => OrgBoardEndpoints.Map(endpoints);
}
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609043906_InitialOrgBoard.Designer.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609043906_InitialOrgBoard.Designer.cs
new file mode 100644
index 0000000..6e133a7
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609043906_InitialOrgBoard.Designer.cs
@@ -0,0 +1,165 @@
+//
+using System;
+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("20260609043906_InitialOrgBoard")]
+ partial class InitialOrgBoard
+ {
+ ///
+ 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.Organization", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.HasKey("Id");
+
+ b.ToTable("organizations", "orgboard");
+ });
+
+ modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AgentId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MemberId")
+ .HasColumnType("uuid");
+
+ b.Property("RoleName")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("State")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("TeamId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TeamId");
+
+ b.ToTable("seats", "orgboard");
+ });
+
+ modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("OrganizationId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId");
+
+ b.ToTable("teams", "orgboard");
+ });
+
+ modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AssigneeId")
+ .HasColumnType("uuid");
+
+ b.Property("AssigneeKind")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedByMemberId")
+ .HasColumnType("uuid");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("ParentId")
+ .HasColumnType("uuid");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("TeamId")
+ .HasColumnType("uuid");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TeamId");
+
+ b.HasIndex("AssigneeKind", "AssigneeId");
+
+ b.ToTable("work_items", "orgboard");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609043906_InitialOrgBoard.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609043906_InitialOrgBoard.cs
new file mode 100644
index 0000000..ab40ba6
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609043906_InitialOrgBoard.cs
@@ -0,0 +1,132 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
+{
+ ///
+ public partial class InitialOrgBoard : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "orgboard");
+
+ migrationBuilder.CreateTable(
+ name: "organizations",
+ schema: "orgboard",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false),
+ CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_organizations", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "seats",
+ schema: "orgboard",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ TeamId = table.Column(type: "uuid", nullable: false),
+ RoleName = table.Column(type: "character varying(120)", maxLength: 120, nullable: false),
+ State = table.Column(type: "character varying(16)", maxLength: 16, nullable: false),
+ MemberId = table.Column(type: "uuid", nullable: true),
+ AgentId = table.Column(type: "uuid", nullable: true),
+ CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_seats", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "teams",
+ schema: "orgboard",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ OrganizationId = table.Column(type: "uuid", nullable: false),
+ Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false),
+ CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_teams", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "work_items",
+ schema: "orgboard",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ TeamId = table.Column(type: "uuid", nullable: false),
+ Title = table.Column(type: "character varying(300)", maxLength: 300, nullable: false),
+ Description = table.Column(type: "text", nullable: true),
+ Type = table.Column(type: "character varying(16)", maxLength: 16, nullable: false),
+ Status = table.Column(type: "character varying(16)", maxLength: 16, nullable: false),
+ AssigneeKind = table.Column(type: "character varying(16)", maxLength: 16, nullable: false),
+ AssigneeId = table.Column