Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions app/(dashboard)/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
createAsset,
updateAsset,
deleteAsset,
setAssetOwners,
createCodePlan,
updateCodePlan,
deleteCodePlan,
Expand All @@ -37,6 +38,7 @@ import {
linkPlanToExternalScope,
unlinkPlanFromExternalScope,
} from '@/lib/db/mutations'
import { getAssetOptions } from '@/lib/db/queries'
import type { UserRole, WorkItemType, WorkItemStatus, WorkItemSeverity } from '@/lib/types'

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -220,6 +222,15 @@ export async function deleteAssetAction(id: string, productSlug: string) {
revalidatePath(`/products/${productSlug}`)
}

export async function setAssetOwnersAction(assetId: string, productSlug: string, userIds: string[]) {
const authUser = await requireUser()
const accessible = await getAssetOptions(authUser.id)
if (!accessible.some((a) => a.id === assetId)) throw new Error('Asset not found or not accessible')
await setAssetOwners(assetId, userIds)
revalidatePath(`/products/${productSlug}`)
revalidatePath('/my-work')
}

// ---------------------------------------------------------------------------
// Code Plans
// ---------------------------------------------------------------------------
Expand Down
61 changes: 58 additions & 3 deletions app/(dashboard)/my-work/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import Link from 'next/link'
import { authAdapter } from '@/lib/auth'
import { getTasks, getCodePlans, getWorkItems } from '@/lib/db/queries'
import { getTasks, getCodePlans, getWorkItems, getOwnedAssets } from '@/lib/db/queries'
import { getProductScope } from '@/lib/product-scope'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Circle, Play, CheckSquare, FileCode2, ClipboardList, CalendarClock } from 'lucide-react'
import { Circle, Play, CheckSquare, FileCode2, ClipboardList, CalendarClock, Box } from 'lucide-react'
import { cn, formatDateShort } from '@/lib/utils'

const priorityStyles: Record<string, string> = {
Expand All @@ -20,10 +20,11 @@ export default async function MyWorkPage() {
if (!user) return null
const scope = await getProductScope()

const [allMyTasks, plans, items] = await Promise.all([
const [allMyTasks, plans, items, ownedAssets] = await Promise.all([
getTasks(user.id, { assigneeId: user.id, productId: scope ?? undefined }),
getCodePlans(user.id, { productId: scope ?? undefined }),
getWorkItems(user.id, { productId: scope ?? undefined }),
getOwnedAssets(user.id),
])

const myTasks = allMyTasks
Expand Down Expand Up @@ -151,6 +152,60 @@ export default async function MyWorkPage() {
</CardContent>
</Card>
</div>

{/* Assets I own */}
{ownedAssets.length > 0 && (
<Card className="bg-card border-border">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Box className="h-4 w-4" />
Assets I Own ({ownedAssets.length})
</CardTitle>
</CardHeader>
<CardContent>
<ul className="divide-y divide-border">
{ownedAssets.map((asset) => (
<li key={asset.id} className="flex items-center justify-between gap-3 py-2.5">
<div className="min-w-0">
<Link href={`/products/${asset.productSlug}`} className="text-sm font-medium truncate block hover:text-accent transition-colors">
{asset.name}
</Link>
<p className="text-xs text-muted-foreground">{asset.productName}</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-xs text-muted-foreground">
{asset.openItemCount} open item{asset.openItemCount === 1 ? '' : 's'}
</span>
<div className="flex items-center gap-2">
<div className="h-1.5 w-24 rounded-full bg-muted overflow-hidden">
<div
className={cn(
'h-full rounded-full',
asset.effectiveDebtScore < 25 ? 'bg-accent' : asset.effectiveDebtScore < 50 ? 'bg-warning' : 'bg-destructive'
)}
style={{ width: `${Math.min(asset.effectiveDebtScore, 100)}%` }}
/>
</div>
<span className="text-xs font-medium w-6 text-right">{asset.effectiveDebtScore}</span>
</div>
<Badge
variant="secondary"
className={cn(
'text-xs capitalize',
asset.health === 'healthy' && 'bg-accent/20 text-accent',
asset.health === 'warning' && 'bg-warning/20 text-warning',
asset.health === 'critical' && 'bg-destructive/20 text-destructive',
)}
>
{asset.health}
</Badge>
</div>
</li>
))}
</ul>
</CardContent>
</Card>
)}
</div>
)
}
84 changes: 78 additions & 6 deletions app/(dashboard)/products/[slug]/assets-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,14 @@ import {
CheckCircle2,
XCircle,
ExternalLink,
X,
UserPlus,
} from 'lucide-react'
import type { Asset, AssetType } from '@/lib/types'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { createAssetAction, updateAssetAction, deleteAssetAction } from '../../actions'
import { OwnerAvatars } from '@/components/owner-avatars'
import { createAssetAction, updateAssetAction, deleteAssetAction, setAssetOwnersAction } from '../../actions'

const assetTypeIcons: Record<AssetType, typeof Box> = {
app: Box,
Expand Down Expand Up @@ -120,14 +123,18 @@ export function AssetCreatePanel({ productId, productSlug, open: controlledOpen,
)
}

export type MemberOption = { id: string; name: string }

export function AssetsSection({
assets,
productId,
productSlug,
members = [],
}: {
assets: Asset[]
productId: string
productSlug: string
members?: MemberOption[]
}) {
const [openAsset, setOpenAsset] = useState<Asset | null>(null)

Expand Down Expand Up @@ -176,7 +183,7 @@ export function AssetsSection({
<Sheet open={!!currentAsset} onOpenChange={(o) => { if (!o) setOpenAsset(null) }}>
<SheetContent className="w-full overflow-y-auto sm:max-w-lg">
{currentAsset && (
<AssetEditor key={currentAsset.id} asset={currentAsset} productSlug={productSlug} onDeleted={() => setOpenAsset(null)} />
<AssetEditor key={currentAsset.id} asset={currentAsset} productSlug={productSlug} members={members} onDeleted={() => setOpenAsset(null)} />
)}
</SheetContent>
</Sheet>
Expand Down Expand Up @@ -212,10 +219,13 @@ function AssetCard({ asset, onOpen }: { asset: Asset; onOpen: (asset: Asset) =>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground line-clamp-2">{asset.description}</p>
<div className="flex flex-wrap gap-1.5">
{asset.tags.slice(0, 4).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
))}
<div className="flex items-center justify-between gap-2">
<div className="flex flex-wrap gap-1.5">
{asset.tags.slice(0, 4).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
))}
</div>
<OwnerAvatars owners={asset.owners ?? []} className="shrink-0" />
</div>
{(() => {
const score = asset.techDebtScore ?? asset.derivedTechDebtScore
Expand Down Expand Up @@ -250,18 +260,32 @@ function AssetCard({ asset, onOpen }: { asset: Asset; onOpen: (asset: Asset) =>
function AssetEditor({
asset,
productSlug,
members,
onDeleted,
}: {
asset: Asset
productSlug: string
members: MemberOption[]
onDeleted: () => void
}) {
const [isPending, startTransition] = useTransition()
const formRef = useRef<HTMLFormElement>(null)
const [type, setType] = useState<string>(asset.type)
const [health, setHealth] = useState<string>(asset.health)
const [addOwnerId, setAddOwnerId] = useState('')
const lastSaved = useRef<string>('')

const owners = asset.owners ?? []
const ownerIds = new Set(owners.map((o) => o.id))
const addableMembers = members.filter((m) => !ownerIds.has(m.id))

function saveOwners(userIds: string[]) {
startTransition(async () => {
await setAssetOwnersAction(asset.id, productSlug, userIds)
toast.success('Owners updated')
})
}

// Baseline the dirty-check on mount: focusing/blurring without edits must not save.
useEffect(() => {
const form = formRef.current
Expand Down Expand Up @@ -362,6 +386,54 @@ function AssetEditor({
<p className="text-xs text-muted-foreground">{isPending ? 'Saving…' : 'Changes save automatically'}</p>
</form>

{/* Owners — routing and visibility (like code owners), not permissions */}
<div className="px-4 space-y-2">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Owners</p>
{owners.length > 0 ? (
<ul className="space-y-1.5">
{owners.map((owner) => (
<li key={owner.id} className="flex items-center justify-between gap-2 text-sm">
<span className="flex items-center gap-2 min-w-0">
<OwnerAvatars owners={[owner]} max={1} />
<span className="truncate">{owner.name}</span>
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
title="Remove owner"
disabled={isPending}
onClick={() => saveOwners(owners.filter((o) => o.id !== owner.id).map((o) => o.id))}
>
<X className="h-3.5 w-3.5" />
</Button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">No owners yet — assign someone to route work here.</p>
)}
{addableMembers.length > 0 && (
<div className="flex items-center gap-2">
<Select value={addOwnerId} onValueChange={setAddOwnerId}>
<SelectTrigger className="h-8 flex-1 text-sm"><SelectValue placeholder="Add an owner…" /></SelectTrigger>
<SelectContent>
{addableMembers.map((m) => <SelectItem key={m.id} value={m.id}>{m.name}</SelectItem>)}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
disabled={!addOwnerId || isPending}
onClick={() => { const id = addOwnerId; setAddOwnerId(''); saveOwners([...owners.map((o) => o.id), id]) }}
>
<UserPlus className="mr-1.5 h-3.5 w-3.5" />
Add
</Button>
</div>
)}
</div>

<SheetFooter className="flex-row justify-end gap-2">
<AlertDialog>
<AlertDialogTrigger asChild>
Expand Down
11 changes: 9 additions & 2 deletions app/(dashboard)/products/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { authAdapter } from '@/lib/auth'
import { getProduct, getCodePlans, getProductDependencyEdges } from '@/lib/db/queries'
import { getProduct, getCodePlans, getProductDependencyEdges, getTeamMembers } from '@/lib/db/queries'
import { db } from '@/lib/db'
import { users } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
import { DependenciesSection } from './dependencies-section'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
Expand All @@ -28,6 +31,10 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
const productPlans = plans.filter((p) => p.productId === product.id)
const dependencyEdges = await getProductDependencyEdges(product.id)

const profile = await db.query.users.findFirst({ where: eq(users.id, user.id) })
const teamMembers = profile?.organizationId ? await getTeamMembers(profile.organizationId) : []
const memberList = teamMembers.map((m) => ({ id: m.userId, name: m.user.name }))

return (
<div className="space-y-6">
<div>
Expand Down Expand Up @@ -71,7 +78,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
</TabsList>

<TabsContent value="assets" className="space-y-6">
<AssetsSection assets={product.assets} productId={product.id} productSlug={slug} />
<AssetsSection assets={product.assets} productId={product.id} productSlug={slug} members={memberList} />
</TabsContent>

<TabsContent value="dependencies" className="space-y-6">
Expand Down
4 changes: 4 additions & 0 deletions app/(dashboard)/work-items/work-items-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Filter, Plus, Circle, Play, CheckCircle2, Wrench, ExternalLink, List, L
import type { WorkItemStatus, WorkItemType } from '@/lib/types'
import type { WorkItemWithContext, AssetDebtInfo } from '@/lib/db/queries'
import { cn } from '@/lib/utils'
import { OwnerAvatars } from '@/components/owner-avatars'
import {
WorkItemPanel,
TYPES,
Expand Down Expand Up @@ -432,6 +433,9 @@ function DebtRegister({
{group.asset.health}
</Badge>
)}
{group.asset && group.asset.owners.length > 0 && (
<OwnerAvatars owners={group.asset.owners} />
)}
</div>
<p className="text-xs text-muted-foreground">{group.product}</p>
</div>
Expand Down
20 changes: 16 additions & 4 deletions app/api/mcp/[transport]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
updateProduct,
createAsset,
updateAsset,
setAssetOwners,
createAssetDependency,
deleteAssetDependency,
createWorkItem,
Expand Down Expand Up @@ -202,17 +203,23 @@ const handler = createMcpHandler(
repositoryUrl: z.string().optional(),
repoPath: z.string().optional(),
documentationUrl: z.string().optional(),
ownerEmails: z.array(z.string()).optional(),
},
async (args, extra) => {
async ({ ownerEmails, ...args }, extra) => {
requireWrite(extra)
await assertProductAccess(uid(extra), args.productId)
return json(await createAsset(args))
const asset = await createAsset(args)
if (ownerEmails !== undefined) {
const ownerIds = await Promise.all(ownerEmails.map((e) => resolveAssigneeEmail(uid(extra), e)))
await setAssetOwners(asset.id, ownerIds)
}
return json(asset)
},
)

server.tool(
'update_asset',
'Update an asset: description, tags, health, manual tech-debt score, repo details.',
'Update an asset: description, tags, health, manual tech-debt score, repo details, owners (ownerEmails replaces the full owner set; [] clears it).',
{
id: z.string(),
name: z.string().optional(),
Expand All @@ -223,11 +230,16 @@ const handler = createMcpHandler(
repositoryUrl: z.string().optional(),
repoPath: z.string().optional(),
documentationUrl: z.string().optional(),
ownerEmails: z.array(z.string()).optional(),
},
async ({ id, ...data }, extra) => {
async ({ id, ownerEmails, ...data }, extra) => {
requireWrite(extra)
const options = await getAssetOptions(uid(extra))
if (!options.some((a) => a.id === id)) return json({ error: 'Asset not found or not accessible' })
if (ownerEmails !== undefined) {
const ownerIds = await Promise.all(ownerEmails.map((e) => resolveAssigneeEmail(uid(extra), e)))
await setAssetOwners(id, ownerIds)
}
return json(await updateAsset(id, data))
},
)
Expand Down
Loading
Loading