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
27 changes: 22 additions & 5 deletions app/(dashboard)/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { getSpec, linkSpec } from '@/lib/db/specs'
import { createdBy } from '@/lib/db/attribution'
import { isOrgOwner, canDeleteCodePlan, canDeleteRelease, canDeleteWorkItem, canDeleteTask } from '@/lib/db/authz'
import { isOrgOwner, canDeleteCodePlan, canDeleteRelease, canDeleteWorkItem, canDeleteTask, canDeleteAsset } from '@/lib/db/authz'
import { getWorkItem } from '@/lib/db/queries'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
Expand All @@ -17,7 +17,8 @@ import {
deleteProduct,
createAsset,
updateAsset,
deleteAsset,
archiveAsset,
restoreAsset,
setAssetOwners,
createCodePlan,
updateCodePlan,
Expand Down Expand Up @@ -198,10 +199,26 @@ export async function updateAssetAction(id: string, productSlug: string, formDat
revalidatePath(`/products/${productSlug}`)
}

export async function deleteAssetAction(id: string, productSlug: string) {
await requireUser()
await deleteAsset(id, await currentEditor())
export async function archiveAssetAction(id: string, productSlug: string, reason?: string) {
const authUser = await requireUser()
if (!(await canDeleteAsset(authUser.id, id))) {
return { error: 'Only the asset\'s creator/owner, or an org owner/admin, can archive it.' }
}
await archiveAsset(id, reason, await currentEditor())
revalidatePath(`/products/${productSlug}`)
revalidatePath(`/assets/${id}`)
revalidatePath('/assets')
}

export async function restoreAssetAction(id: string, productSlug: string) {
const authUser = await requireUser()
if (!(await canDeleteAsset(authUser.id, id))) {
return { error: 'Only the asset\'s creator/owner, or an org owner/admin, can restore it.' }
}
await restoreAsset(id, await currentEditor())
revalidatePath(`/products/${productSlug}`)
revalidatePath(`/assets/${id}`)
revalidatePath('/assets')
}

export async function setAssetOwnersAction(assetId: string, productSlug: string, userIds: string[]) {
Expand Down
116 changes: 86 additions & 30 deletions app/(dashboard)/products/[slug]/assets-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ import {
import {
Plus,
Pencil,
Trash2,
Archive,
ArchiveRestore,
Box,
Server,
Library,
Expand All @@ -49,7 +50,7 @@ import type { Asset, AssetType } from '@/lib/types'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { OwnerAvatars } from '@/components/owner-avatars'
import { createAssetAction, updateAssetAction, deleteAssetAction, setAssetOwnersAction } from '../../actions'
import { createAssetAction, updateAssetAction, archiveAssetAction, restoreAssetAction, setAssetOwnersAction } from '../../actions'

const assetTypeIcons: Record<AssetType, typeof Box> = {
app: Box,
Expand Down Expand Up @@ -138,11 +139,15 @@ export function AssetsSection({
members?: MemberOption[]
}) {
const [openAsset, setOpenAsset] = useState<Asset | null>(null)
const [showArchived, setShowArchived] = useState(false)

// Keep the panel in sync with refreshed server data after an edit
const currentAsset = openAsset ? assets.find((a) => a.id === openAsset.id) ?? null : null

const assetsByType = assets.reduce((acc, asset) => {
const activeAssets = assets.filter((a) => !a.archivedAt)
const archivedAssets = assets.filter((a) => a.archivedAt)

const assetsByType = activeAssets.reduce((acc, asset) => {
if (!acc[asset.type]) acc[asset.type] = []
acc[asset.type].push(asset)
return acc
Expand All @@ -168,7 +173,7 @@ export function AssetsSection({
)
})}

{assets.length === 0 && (
{activeAssets.length === 0 && (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12">
<Box className="h-12 w-12 text-muted-foreground mb-4" />
Expand All @@ -181,6 +186,29 @@ export function AssetsSection({
</Card>
)}

{archivedAssets.length > 0 && (
<div>
<button
type="button"
className="flex items-center gap-2 mb-4 text-muted-foreground hover:text-foreground"
onClick={() => setShowArchived((v) => !v)}
>
<Archive className="h-4 w-4" />
<h2 className="text-sm font-medium">
{showArchived ? 'Hide' : 'Show'} archived assets
</h2>
<Badge variant="secondary">{archivedAssets.length}</Badge>
</button>
{showArchived && (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 opacity-70">
{archivedAssets.map((asset) => (
<AssetCard key={asset.id} asset={asset} onOpen={setOpenAsset} />
))}
</div>
)}
</div>
)}

<Sheet open={!!currentAsset} onOpenChange={(o) => { if (!o) setOpenAsset(null) }}>
<SheetContent className="w-full overflow-y-auto sm:max-w-lg">
{currentAsset && (
Expand Down Expand Up @@ -209,7 +237,10 @@ function AssetCard({ asset, onOpen }: { asset: Asset; onOpen: (asset: Asset) =>
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<CardTitle className="text-base">{asset.name}</CardTitle>
<div className="flex items-center gap-2">
<CardTitle className="text-base">{asset.name}</CardTitle>
{asset.archivedAt && <Badge variant="secondary" className="text-xs">Archived</Badge>}
</div>
<p className="text-sm text-muted-foreground">{assetTypeLabels[asset.type]}</p>
</div>
</div>
Expand Down Expand Up @@ -323,13 +354,28 @@ function AssetEditor({
})
}

function handleDelete() {
function handleArchive() {
startTransition(async () => {
await deleteAssetAction(asset.id, productSlug)
const result = await archiveAssetAction(asset.id, productSlug)
if (result?.error) {
toast.error(result.error)
return
}
onDeleted()
})
}

function handleRestore() {
startTransition(async () => {
const result = await restoreAssetAction(asset.id, productSlug)
if (result?.error) {
toast.error(result.error)
return
}
toast.success('Asset restored')
})
}

return (
<>
<SheetHeader>
Expand All @@ -338,7 +384,10 @@ function AssetEditor({
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<SheetTitle className="text-lg">{asset.name}</SheetTitle>
<div className="flex items-center gap-2">
<SheetTitle className="text-lg">{asset.name}</SheetTitle>
{asset.archivedAt && <Badge variant="secondary">Archived</Badge>}
</div>
<SheetDescription>{assetTypeLabels[asset.type]}</SheetDescription>
</div>
</div>
Expand Down Expand Up @@ -458,28 +507,35 @@ function AssetEditor({
</div>

<SheetFooter className="flex-row justify-end gap-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="text-destructive hover:text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete asset?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &ldquo;{asset.name}&rdquo;. Tasks and work items pointing at it lose their asset link. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{isPending ? 'Deleting…' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{asset.archivedAt ? (
<Button variant="outline" size="sm" onClick={handleRestore} disabled={isPending}>
<ArchiveRestore className="mr-2 h-4 w-4" />
{isPending ? 'Restoring…' : 'Restore'}
</Button>
) : (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="text-destructive hover:text-destructive">
<Archive className="mr-2 h-4 w-4" />
Archive
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Archive this asset?</AlertDialogTitle>
<AlertDialogDescription>
&ldquo;{asset.name}&rdquo; will be hidden from asset lists, pickers, and Atlas. Tasks, work items, dependency edges, and plan/release links that already point at it keep working — nothing is deleted or unlinked. You can restore it later.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleArchive} disabled={isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{isPending ? 'Archiving…' : 'Archive'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</SheetFooter>
</>
)
Expand Down
56 changes: 55 additions & 1 deletion app/api/mcp/[transport]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createSpec, updateSpec, supersedeSpec, linkSpec, unlinkSpec, getSpec, l
import { createMcpHandler, withMcpAuth } from 'mcp-handler'
import { z } from 'zod'
import { verifyApiKey } from '@/lib/mcp/auth'
import { canDeleteCodePlan, canDeleteRelease, canDeleteWorkItem, canDeleteTask } from '@/lib/db/authz'
import { canDeleteCodePlan, canDeleteRelease, canDeleteWorkItem, canDeleteTask, canDeleteAsset } from '@/lib/db/authz'
import {
getProducts,
getProduct,
Expand All @@ -21,6 +21,8 @@ import {
updateProduct,
createAsset,
updateAsset,
archiveAsset,
restoreAsset,
setAssetOwners,
createAssetDependency,
deleteAssetDependency,
Expand Down Expand Up @@ -325,6 +327,58 @@ const handler = createMcpHandler(
},
)

server.tool(
'archive_asset',
"Archive an asset — the delete-equivalent action for assets. Hides it from asset lists/pickers and Atlas, but does NOT delete it or touch anything referencing it: work items, tasks, dependency edges, plan/release links all keep their reference (they just can no longer be newly assigned to it). Reversible via restore_asset. Only the asset's creator/owner, or an org owner/admin, may archive it.",
{ id: z.string(), reason: z.string().optional() },
async ({ id, reason }, extra) => {
requireWrite(extra)
const userId = uid(extra)
const options = await getAssetOptions(userId)
if (!options.some((a) => a.id === id)) return json({ error: 'Asset not found, not accessible, or already archived' })
if (!(await canDeleteAsset(userId, id))) {
return json({ error: "Only the asset's creator/owner, or an org owner/admin, can archive it." })
}
const { db } = await import('@/lib/db')
const { workItems, tasks, assetDependencies, codePlanAssets, releaseAssets } = await import('@/lib/db/schema')
const { eq, or } = await import('drizzle-orm')
const [workItemRows, taskRows, depRows, planAssetRows, releaseAssetRows] = await Promise.all([
db.select({ id: workItems.id }).from(workItems).where(eq(workItems.assetId, id)),
db.select({ id: tasks.id }).from(tasks).where(eq(tasks.assetId, id)),
db.select({ id: assetDependencies.id }).from(assetDependencies).where(or(eq(assetDependencies.sourceAssetId, id), eq(assetDependencies.targetAssetId, id))),
db.select({ id: codePlanAssets.id }).from(codePlanAssets).where(eq(codePlanAssets.assetId, id)),
db.select({ id: releaseAssets.id }).from(releaseAssets).where(eq(releaseAssets.assetId, id)),
])
const archived = await archiveAsset(id, reason, { id: userId, kind: 'agent' })
if (!archived) return json({ error: 'Asset not found' })
return json({
archived: true,
id,
referencedByWorkItemCount: workItemRows.length,
referencedByTaskCount: taskRows.length,
dependencyEdgeCount: depRows.length,
planTargetCount: planAssetRows.length,
releaseStampCount: releaseAssetRows.length,
note: 'None of the above were deleted or unlinked — they keep referencing this asset, it just no longer appears in lists or pickers until restored.',
})
},
)

server.tool(
'restore_asset',
'Restore a previously archived asset, making it visible again in lists, pickers, and Atlas. Same authorization as archive_asset.',
{ id: z.string() },
async ({ id }, extra) => {
requireWrite(extra)
const userId = uid(extra)
if (!(await canDeleteAsset(userId, id))) {
return json({ error: "Only the asset's creator/owner, or an org owner/admin, can restore it." })
}
const restored = await restoreAsset(id, { id: userId, kind: 'agent' })
return json(restored ?? { error: 'Asset not found' })
},
)

server.tool(
'add_asset_dependency',
'Record that one asset depends on / integrates with / aggregates another — powers plan impact analysis. Map COORDINATION RISK, not the import graph: only edges where a change forces cross-asset coordination. Twenty curated edges beat two hundred stale ones.',
Expand Down
19 changes: 18 additions & 1 deletion lib/db/authz.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { db } from './index'
import { organizations, organizationMembers, products, codePlans, releases, workItems, tasks } from './schema'
import { organizations, organizationMembers, products, codePlans, releases, workItems, tasks, assets, assetOwners } from './schema'
import { eq, and } from 'drizzle-orm'

/**
Expand Down Expand Up @@ -80,3 +80,20 @@ export async function canDeleteTask(userId: string, taskId: string): Promise<boo
if (plan && (await hasOrgOverride(await getProductOrgId(plan.productId), userId))) return true
return task.createdById === userId || task.assigneeId === userId
}

/**
* Governs both archiveAsset and restoreAsset (Phase 3) — archiving is the
* delete-equivalent action for assets, so it follows the same rule. "Owns
* it" means listed in assetOwners (declared code-owner routing/visibility),
* the closest asset analog to a task's assigneeId.
*/
export async function canDeleteAsset(userId: string, assetId: string): Promise<boolean> {
const asset = await db.query.assets.findFirst({ where: eq(assets.id, assetId) })
if (!asset) return false
if (await hasOrgOverride(await getProductOrgId(asset.productId), userId)) return true
if (asset.createdById === userId) return true
const owner = await db.query.assetOwners.findFirst({
where: and(eq(assetOwners.assetId, assetId), eq(assetOwners.userId, userId)),
})
return !!owner
}
7 changes: 7 additions & 0 deletions lib/db/migrations/postgres/0021_phase3_asset_archive.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
ALTER TABLE "assets" ADD COLUMN "archived_at" timestamp with time zone;
--> statement-breakpoint
ALTER TABLE "assets" ADD COLUMN "archived_by_id" uuid;
--> statement-breakpoint
ALTER TABLE "assets" ADD COLUMN "archived_by_kind" text;
--> statement-breakpoint
ALTER TABLE "assets" ADD CONSTRAINT "assets_archived_by_id_users_id_fk" FOREIGN KEY ("archived_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
7 changes: 7 additions & 0 deletions lib/db/migrations/postgres/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@
"when": 1789412521000,
"tag": "0020_backfill_creator_attribution",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1789420000000,
"tag": "0021_phase3_asset_archive",
"breakpoints": true
}
]
}
3 changes: 3 additions & 0 deletions lib/db/migrations/sqlite/0021_phase3_asset_archive.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE `assets` ADD `archived_at` integer;--> statement-breakpoint
ALTER TABLE `assets` ADD `archived_by_id` text REFERENCES users(id);--> statement-breakpoint
ALTER TABLE `assets` ADD `archived_by_kind` text;
Loading
Loading