diff --git a/app/(dashboard)/actions.ts b/app/(dashboard)/actions.ts index b0addfc..a48682b 100644 --- a/app/(dashboard)/actions.ts +++ b/app/(dashboard)/actions.ts @@ -14,6 +14,7 @@ import { createAsset, updateAsset, deleteAsset, + setAssetOwners, createCodePlan, updateCodePlan, deleteCodePlan, @@ -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' // --------------------------------------------------------------------------- @@ -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 // --------------------------------------------------------------------------- diff --git a/app/(dashboard)/my-work/page.tsx b/app/(dashboard)/my-work/page.tsx index 06305e7..9d67f00 100644 --- a/app/(dashboard)/my-work/page.tsx +++ b/app/(dashboard)/my-work/page.tsx @@ -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 = { @@ -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 @@ -151,6 +152,60 @@ export default async function MyWorkPage() { + + {/* Assets I own */} + {ownedAssets.length > 0 && ( + + + + + Assets I Own ({ownedAssets.length}) + + + +
    + {ownedAssets.map((asset) => ( +
  • +
    + + {asset.name} + +

    {asset.productName}

    +
    +
    + + {asset.openItemCount} open item{asset.openItemCount === 1 ? '' : 's'} + +
    +
    +
    +
    + {asset.effectiveDebtScore} +
    + + {asset.health} + +
    +
  • + ))} +
+
+
+ )} ) } diff --git a/app/(dashboard)/products/[slug]/assets-section.tsx b/app/(dashboard)/products/[slug]/assets-section.tsx index 89f9ba5..eccbdff 100644 --- a/app/(dashboard)/products/[slug]/assets-section.tsx +++ b/app/(dashboard)/products/[slug]/assets-section.tsx @@ -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 = { app: Box, @@ -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(null) @@ -176,7 +183,7 @@ export function AssetsSection({ { if (!o) setOpenAsset(null) }}> {currentAsset && ( - setOpenAsset(null)} /> + setOpenAsset(null)} /> )} @@ -212,10 +219,13 @@ function AssetCard({ asset, onOpen }: { asset: Asset; onOpen: (asset: Asset) =>

{asset.description}

-
- {asset.tags.slice(0, 4).map((tag) => ( - {tag} - ))} +
+
+ {asset.tags.slice(0, 4).map((tag) => ( + {tag} + ))} +
+
{(() => { const score = asset.techDebtScore ?? asset.derivedTechDebtScore @@ -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(null) const [type, setType] = useState(asset.type) const [health, setHealth] = useState(asset.health) + const [addOwnerId, setAddOwnerId] = useState('') const lastSaved = useRef('') + 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 @@ -362,6 +386,54 @@ function AssetEditor({

{isPending ? 'Saving…' : 'Changes save automatically'}

+ {/* Owners — routing and visibility (like code owners), not permissions */} +
+

Owners

+ {owners.length > 0 ? ( +
    + {owners.map((owner) => ( +
  • + + + {owner.name} + + +
  • + ))} +
+ ) : ( +

No owners yet — assign someone to route work here.

+ )} + {addableMembers.length > 0 && ( +
+ + +
+ )} +
+ diff --git a/app/(dashboard)/products/[slug]/page.tsx b/app/(dashboard)/products/[slug]/page.tsx index 6d0fe17..9b8ee3c 100644 --- a/app/(dashboard)/products/[slug]/page.tsx +++ b/app/(dashboard)/products/[slug]/page.tsx @@ -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' @@ -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 (
@@ -71,7 +78,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{ - + diff --git a/app/(dashboard)/work-items/work-items-client.tsx b/app/(dashboard)/work-items/work-items-client.tsx index 03392c5..0f0df7b 100644 --- a/app/(dashboard)/work-items/work-items-client.tsx +++ b/app/(dashboard)/work-items/work-items-client.tsx @@ -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, @@ -432,6 +433,9 @@ function DebtRegister({ {group.asset.health} )} + {group.asset && group.asset.owners.length > 0 && ( + + )}

{group.product}

diff --git a/app/api/mcp/[transport]/route.ts b/app/api/mcp/[transport]/route.ts index 9430834..b4793af 100644 --- a/app/api/mcp/[transport]/route.ts +++ b/app/api/mcp/[transport]/route.ts @@ -15,6 +15,7 @@ import { updateProduct, createAsset, updateAsset, + setAssetOwners, createAssetDependency, deleteAssetDependency, createWorkItem, @@ -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(), @@ -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)) }, ) diff --git a/components/owner-avatars.tsx b/components/owner-avatars.tsx new file mode 100644 index 0000000..b723e95 --- /dev/null +++ b/components/owner-avatars.tsx @@ -0,0 +1,47 @@ +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { cn } from '@/lib/utils' +import type { AssetOwner } from '@/lib/types' + +function initials(name: string) { + return name + .split(/\s+/) + .map((part) => part[0]) + .filter(Boolean) + .slice(0, 2) + .join('') + .toUpperCase() +} + +/** Overlapping avatar stack for asset owners; shows up to `max` with a +N overflow chip. */ +export function OwnerAvatars({ + owners, + max = 3, + size = 'sm', + className, +}: { + owners: AssetOwner[] + max?: number + size?: 'sm' | 'md' + className?: string +}) { + if (owners.length === 0) return null + const shown = owners.slice(0, max) + const overflow = owners.length - shown.length + const sizeClass = size === 'sm' ? 'size-6 text-[10px]' : 'size-8 text-xs' + + return ( +
o.name).join(', ')}`}> + {shown.map((owner) => ( + + {owner.avatarUrl && } + {initials(owner.name)} + + ))} + {overflow > 0 && ( +
+ +{overflow} +
+ )} +
+ ) +} diff --git a/lib/db/migrations/postgres/0011_asset_owners.sql b/lib/db/migrations/postgres/0011_asset_owners.sql new file mode 100644 index 0000000..36e0b5c --- /dev/null +++ b/lib/db/migrations/postgres/0011_asset_owners.sql @@ -0,0 +1,11 @@ +CREATE TABLE "asset_owners" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "asset_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "asset_owners" ADD CONSTRAINT "asset_owners_asset_id_assets_id_fk" FOREIGN KEY ("asset_id") REFERENCES "public"."assets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "asset_owners" ADD CONSTRAINT "asset_owners_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "asset_owners_asset_user_idx" ON "asset_owners" USING btree ("asset_id","user_id");--> statement-breakpoint +CREATE INDEX "asset_owners_user_idx" ON "asset_owners" USING btree ("user_id"); diff --git a/lib/db/migrations/postgres/meta/_journal.json b/lib/db/migrations/postgres/meta/_journal.json index f56ca84..8beb791 100644 --- a/lib/db/migrations/postgres/meta/_journal.json +++ b/lib/db/migrations/postgres/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1784058835573, "tag": "0010_drop_plan_assignees", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1784169761720, + "tag": "0011_asset_owners", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/lib/db/migrations/sqlite/0011_asset_owners.sql b/lib/db/migrations/sqlite/0011_asset_owners.sql new file mode 100644 index 0000000..a69bb08 --- /dev/null +++ b/lib/db/migrations/sqlite/0011_asset_owners.sql @@ -0,0 +1,11 @@ +CREATE TABLE `asset_owners` ( + `id` text PRIMARY KEY NOT NULL, + `asset_id` text NOT NULL, + `user_id` text NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`asset_id`) REFERENCES `assets`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `asset_owners_asset_user_idx` ON `asset_owners` (`asset_id`,`user_id`);--> statement-breakpoint +CREATE INDEX `asset_owners_user_idx` ON `asset_owners` (`user_id`); \ No newline at end of file diff --git a/lib/db/migrations/sqlite/meta/0011_snapshot.json b/lib/db/migrations/sqlite/meta/0011_snapshot.json new file mode 100644 index 0000000..7314dc0 --- /dev/null +++ b/lib/db/migrations/sqlite/meta/0011_snapshot.json @@ -0,0 +1,2041 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6d4f2c1c-98f4-4e38-8148-31601cbc0486", + "prevId": "1289b0db-bfaa-41e4-8db3-cc14ec419502", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'read'" + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "asset_dependencies": { + "name": "asset_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_asset_id": { + "name": "source_asset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_asset_id": { + "name": "target_asset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dependency_type": { + "name": "dependency_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "asset_dependencies_source_asset_id_assets_id_fk": { + "name": "asset_dependencies_source_asset_id_assets_id_fk", + "tableFrom": "asset_dependencies", + "tableTo": "assets", + "columnsFrom": [ + "source_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_dependencies_target_asset_id_assets_id_fk": { + "name": "asset_dependencies_target_asset_id_assets_id_fk", + "tableFrom": "asset_dependencies", + "tableTo": "assets", + "columnsFrom": [ + "target_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "asset_owners": { + "name": "asset_owners", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "asset_owners_asset_user_idx": { + "name": "asset_owners_asset_user_idx", + "columns": [ + "asset_id", + "user_id" + ], + "isUnique": true + }, + "asset_owners_user_idx": { + "name": "asset_owners_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "asset_owners_asset_id_assets_id_fk": { + "name": "asset_owners_asset_id_assets_id_fk", + "tableFrom": "asset_owners", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_owners_user_id_users_id_fk": { + "name": "asset_owners_user_id_users_id_fk", + "tableFrom": "asset_owners", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assets": { + "name": "assets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "health": { + "name": "health", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'healthy'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "tech_debt_score": { + "name": "tech_debt_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "documentation_url": { + "name": "documentation_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "assets_product_id_products_id_fk": { + "name": "assets_product_id_products_id_fk", + "tableFrom": "assets", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "code_plan_assets": { + "name": "code_plan_assets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "code_plan_id": { + "name": "code_plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_status": { + "name": "pr_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "code_plan_assets_plan_asset_idx": { + "name": "code_plan_assets_plan_asset_idx", + "columns": [ + "code_plan_id", + "asset_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "code_plan_assets_code_plan_id_code_plans_id_fk": { + "name": "code_plan_assets_code_plan_id_code_plans_id_fk", + "tableFrom": "code_plan_assets", + "tableTo": "code_plans", + "columnsFrom": [ + "code_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "code_plan_assets_asset_id_assets_id_fk": { + "name": "code_plan_assets_asset_id_assets_id_fk", + "tableFrom": "code_plan_assets", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "code_plans": { + "name": "code_plans", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "start_date": { + "name": "start_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_date": { + "name": "end_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deadline": { + "name": "deadline", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spec_url": { + "name": "spec_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'native'" + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_data": { + "name": "external_data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "external_deleted": { + "name": "external_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "synced_at": { + "name": "synced_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "code_plans_connection_external_idx": { + "name": "code_plans_connection_external_idx", + "columns": [ + "connection_id", + "external_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "code_plans_product_id_products_id_fk": { + "name": "code_plans_product_id_products_id_fk", + "tableFrom": "code_plans", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "code_plans_creator_id_users_id_fk": { + "name": "code_plans_creator_id_users_id_fk", + "tableFrom": "code_plans", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "code_plans_owner_id_users_id_fk": { + "name": "code_plans_owner_id_users_id_fk", + "tableFrom": "code_plans", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "code_plans_connection_id_integrations_id_fk": { + "name": "code_plans_connection_id_integrations_id_fk", + "tableFrom": "code_plans", + "tableTo": "integrations", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_verification_tokens": { + "name": "email_verification_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "new_email": { + "name": "new_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "email_verification_tokens_token_unique": { + "name": "email_verification_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "email_verification_tokens_user_id_users_id_fk": { + "name": "email_verification_tokens_user_id_users_id_fk", + "tableFrom": "email_verification_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrations": { + "name": "integrations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_ref": { + "name": "auth_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_encrypted": { + "name": "token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrations_organization_id_organizations_id_fk": { + "name": "integrations_organization_id_organizations_id_fk", + "tableFrom": "integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'viewer'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_invited_by_users_id_fk": { + "name": "organization_members_invited_by_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "billing_tier": { + "name": "billing_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "product_limit": { + "name": "product_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "organizations_owner_id_users_id_fk": { + "name": "organizations_owner_id_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "products": { + "name": "products", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "products_slug_unique": { + "name": "products_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "products_organization_id_organizations_id_fk": { + "name": "products_organization_id_organizations_id_fk", + "tableFrom": "products", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "products_creator_id_users_id_fk": { + "name": "products_creator_id_users_id_fk", + "tableFrom": "products", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_log": { + "name": "sync_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sync_log_org_created_idx": { + "name": "sync_log_org_created_idx", + "columns": [ + "organization_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_log_organization_id_organizations_id_fk": { + "name": "sync_log_organization_id_organizations_id_fk", + "tableFrom": "sync_log", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_log_connection_id_integrations_id_fk": { + "name": "sync_log_connection_id_integrations_id_fk", + "tableFrom": "sync_log", + "tableTo": "integrations", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "sync_log_actor_id_users_id_fk": { + "name": "sync_log_actor_id_users_id_fk", + "tableFrom": "sync_log", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "code_plan_id": { + "name": "code_plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'not_started'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'medium'" + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "percent_complete": { + "name": "percent_complete", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_date": { + "name": "start_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_date": { + "name": "end_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimated_effort": { + "name": "estimated_effort", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actual_effort": { + "name": "actual_effort", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'native'" + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_data": { + "name": "external_data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "external_deleted": { + "name": "external_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "synced_at": { + "name": "synced_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tasks_connection_external_idx": { + "name": "tasks_connection_external_idx", + "columns": [ + "connection_id", + "external_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "tasks_code_plan_id_code_plans_id_fk": { + "name": "tasks_code_plan_id_code_plans_id_fk", + "tableFrom": "tasks", + "tableTo": "code_plans", + "columnsFrom": [ + "code_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_asset_id_assets_id_fk": { + "name": "tasks_asset_id_assets_id_fk", + "tableFrom": "tasks", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_connection_id_integrations_id_fk": { + "name": "tasks_connection_id_integrations_id_fk", + "tableFrom": "tasks", + "tableTo": "integrations", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "billing_tier": { + "name": "billing_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'viewer'" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feature_flags": { + "name": "feature_flags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "work_item_code_plans": { + "name": "work_item_code_plans", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "work_item_id": { + "name": "work_item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_plan_id": { + "name": "code_plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "work_item_code_plans_item_plan_idx": { + "name": "work_item_code_plans_item_plan_idx", + "columns": [ + "work_item_id", + "code_plan_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "work_item_code_plans_work_item_id_work_items_id_fk": { + "name": "work_item_code_plans_work_item_id_work_items_id_fk", + "tableFrom": "work_item_code_plans", + "tableTo": "work_items", + "columnsFrom": [ + "work_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "work_item_code_plans_code_plan_id_code_plans_id_fk": { + "name": "work_item_code_plans_code_plan_id_code_plans_id_fk", + "tableFrom": "work_item_code_plans", + "tableTo": "code_plans", + "columnsFrom": [ + "code_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "work_items": { + "name": "work_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "area": { + "name": "area", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'medium'" + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spec_url": { + "name": "spec_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'native'" + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_data": { + "name": "external_data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "external_deleted": { + "name": "external_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "synced_at": { + "name": "synced_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "work_items_connection_external_idx": { + "name": "work_items_connection_external_idx", + "columns": [ + "connection_id", + "external_id" + ], + "isUnique": true + }, + "work_items_product_idx": { + "name": "work_items_product_idx", + "columns": [ + "product_id" + ], + "isUnique": false + }, + "work_items_asset_idx": { + "name": "work_items_asset_idx", + "columns": [ + "asset_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "work_items_product_id_products_id_fk": { + "name": "work_items_product_id_products_id_fk", + "tableFrom": "work_items", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "work_items_asset_id_assets_id_fk": { + "name": "work_items_asset_id_assets_id_fk", + "tableFrom": "work_items", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_parent_id_work_items_id_fk": { + "name": "work_items_parent_id_work_items_id_fk", + "tableFrom": "work_items", + "tableTo": "work_items", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_reporter_id_users_id_fk": { + "name": "work_items_reporter_id_users_id_fk", + "tableFrom": "work_items", + "tableTo": "users", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_owner_id_users_id_fk": { + "name": "work_items_owner_id_users_id_fk", + "tableFrom": "work_items", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_connection_id_integrations_id_fk": { + "name": "work_items_connection_id_integrations_id_fk", + "tableFrom": "work_items", + "tableTo": "integrations", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/lib/db/migrations/sqlite/meta/_journal.json b/lib/db/migrations/sqlite/meta/_journal.json index 5a661bd..e2e1652 100644 --- a/lib/db/migrations/sqlite/meta/_journal.json +++ b/lib/db/migrations/sqlite/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1784058835573, "tag": "0010_drop_plan_assignees", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1784169685774, + "tag": "0011_asset_owners", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/mutations.ts b/lib/db/mutations.ts index 55256f0..f87d32f 100644 --- a/lib/db/mutations.ts +++ b/lib/db/mutations.ts @@ -2,6 +2,7 @@ import { db } from './index' import { products, assets, + assetOwners, assetDependencies, integrations, codePlans, @@ -103,6 +104,15 @@ export async function deleteAsset(id: string) { return deleted ?? null } +/** Replace the full owner set for an asset. */ +export async function setAssetOwners(assetId: string, userIds: string[]) { + await db.delete(assetOwners).where(eq(assetOwners.assetId, assetId)) + const unique = [...new Set(userIds)] + if (unique.length > 0) { + await db.insert(assetOwners).values(unique.map((userId) => ({ assetId, userId }))) + } +} + // --------------------------------------------------------------------------- // Code Plans // --------------------------------------------------------------------------- diff --git a/lib/db/queries.ts b/lib/db/queries.ts index f0666f2..60b5f49 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -2,6 +2,7 @@ import { db } from './index' import { products, assets, + assetOwners, assetDependencies, codePlans, codePlanAssets, @@ -18,6 +19,7 @@ import { eq, and, sql, desc, or, inArray, gte, isNotNull } from 'drizzle-orm' import type { Product, Asset, + AssetOwner, CodePlan, CodePlanStatus, PlanAsset, @@ -191,6 +193,30 @@ export async function getProducts(userId: string, productId?: string): Promise

> { + const map = new Map() + if (assetIds.length === 0) return map + const rows = await db + .select({ + assetId: assetOwners.assetId, + id: users.id, + name: users.name, + avatarUrl: users.avatarUrl, + }) + .from(assetOwners) + .innerJoin(users, eq(assetOwners.userId, users.id)) + .where(inArray(assetOwners.assetId, assetIds)) + .orderBy(users.name) + for (const r of rows) { + map.set(r.assetId, [ + ...(map.get(r.assetId) ?? []), + { id: r.id, name: r.name, avatarUrl: r.avatarUrl ?? undefined }, + ]) + } + return map +} + export async function getProduct(slug: string, userId: string): Promise<(Product & { assets: Asset[] }) | null> { const productFilter = and(eq(products.slug, slug), await productAccessWhere(userId)) @@ -202,6 +228,8 @@ export async function getProduct(slug: string, userId: string): Promise<(Product orderBy: desc(assets.createdAt), }) + const owners = await ownersByAsset(productAssets.map((a) => a.id)) + const [activePlanCount] = await db .select({ count: sql`CAST(count(*) AS INTEGER)` }) .from(codePlans) @@ -251,6 +279,7 @@ export async function getProduct(slug: string, userId: string): Promise<(Product techDebtScore: a.techDebtScore ?? undefined, derivedTechDebtScore: derivedByAsset.get(a.id)?.score, openDebtCount: derivedByAsset.get(a.id)?.count ?? 0, + owners: owners.get(a.id) ?? [], repositoryUrl: a.repositoryUrl ?? undefined, repoPath: a.repoPath ?? undefined, documentationUrl: a.documentationUrl ?? undefined, @@ -767,16 +796,91 @@ export type AssetDebtInfo = { health: 'healthy' | 'warning' | 'critical' /** Manual override score, when set. */ techDebtScore: number | null + owners: AssetOwner[] } -/** Health + manual debt-score override for accessible assets — for the tech debt register. */ +/** Health, manual debt-score override, and owners for accessible assets — for the tech debt register. */ export async function getAssetDebtInfo(userId: string): Promise { const productFilter = await productAccessWhere(userId) - return db + const rows = await db .select({ id: assets.id, health: assets.health, techDebtScore: assets.techDebtScore }) .from(assets) .innerJoin(products, eq(assets.productId, products.id)) .where(productFilter) + const owners = await ownersByAsset(rows.map((r) => r.id)) + return rows.map((r) => ({ ...r, owners: owners.get(r.id) ?? [] })) +} + +export type OwnedAsset = { + id: string + name: string + type: Asset['type'] + productName: string + productSlug: string + health: 'healthy' | 'warning' | 'critical' + /** Manual override when set, otherwise severity-weighted from open tech debt. */ + effectiveDebtScore: number + openDebtCount: number + openItemCount: number +} + +/** Assets the user owns, with health and debt rollups — for My Work. */ +export async function getOwnedAssets(userId: string): Promise { + const rows = await db + .select({ + id: assets.id, + name: assets.name, + type: assets.type, + health: assets.health, + techDebtScore: assets.techDebtScore, + productName: products.name, + productSlug: products.slug, + }) + .from(assetOwners) + .innerJoin(assets, eq(assetOwners.assetId, assets.id)) + .innerJoin(products, eq(assets.productId, products.id)) + .where(eq(assetOwners.userId, userId)) + .orderBy(assets.name) + if (rows.length === 0) return [] + + const itemRows = await db + .select({ assetId: workItems.assetId, type: workItems.type, severity: workItems.severity }) + .from(workItems) + .where( + and( + inArray(workItems.assetId, rows.map((r) => r.id)), + inArray(workItems.status, ['open', 'planned', 'in_progress']), + ), + ) + const DEBT_WEIGHT: Record = { low: 3, medium: 8, high: 15, critical: 25 } + const rollup = new Map() + for (const r of itemRows) { + if (!r.assetId) continue + const cur = rollup.get(r.assetId) ?? { debtScore: 0, debtCount: 0, itemCount: 0 } + cur.itemCount += 1 + if (r.type === 'tech_debt') { + cur.debtCount += 1 + cur.debtScore = Math.min(100, cur.debtScore + (DEBT_WEIGHT[r.severity] ?? 8)) + } + rollup.set(r.assetId, cur) + } + + return rows + .map((r) => { + const roll = rollup.get(r.id) + return { + id: r.id, + name: r.name, + type: r.type, + productName: r.productName, + productSlug: r.productSlug, + health: r.health, + effectiveDebtScore: r.techDebtScore ?? roll?.debtScore ?? 0, + openDebtCount: roll?.debtCount ?? 0, + openItemCount: roll?.itemCount ?? 0, + } + }) + .sort((a, b) => b.effectiveDebtScore - a.effectiveDebtScore) } // --------------------------------------------------------------------------- diff --git a/lib/db/schema.pg.ts b/lib/db/schema.pg.ts index 1cdc39a..5d00c55 100644 --- a/lib/db/schema.pg.ts +++ b/lib/db/schema.pg.ts @@ -122,6 +122,18 @@ export const assets = pgTable('assets', { updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }) +// Declared responsibility (like code owners) — routing and visibility, not an ACL. +// Explicit rather than derived: unlike plan assignees, there is no activity to derive it from. +export const assetOwners = pgTable('asset_owners', { + id: uuid('id').primaryKey().defaultRandom(), + assetId: uuid('asset_id').notNull().references(() => assets.id, { onDelete: 'cascade' }), + userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}, (t) => [ + uniqueIndex('asset_owners_asset_user_idx').on(t.assetId, t.userId), + index('asset_owners_user_idx').on(t.userId), +]) + export const assetDependencies = pgTable('asset_dependencies', { id: uuid('id').primaryKey().defaultRandom(), sourceAssetId: uuid('source_asset_id').notNull().references(() => assets.id, { onDelete: 'cascade' }), diff --git a/lib/db/schema.sqlite.ts b/lib/db/schema.sqlite.ts index c1c34e5..d090a93 100644 --- a/lib/db/schema.sqlite.ts +++ b/lib/db/schema.sqlite.ts @@ -116,6 +116,18 @@ export const assets = sqliteTable('assets', { updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), }) +// Declared responsibility (like code owners) — routing and visibility, not an ACL. +// Explicit rather than derived: unlike plan assignees, there is no activity to derive it from. +export const assetOwners = sqliteTable('asset_owners', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + assetId: text('asset_id').notNull().references(() => assets.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), +}, (t) => [ + uniqueIndex('asset_owners_asset_user_idx').on(t.assetId, t.userId), + index('asset_owners_user_idx').on(t.userId), +]) + export const assetDependencies = sqliteTable('asset_dependencies', { id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), sourceAssetId: text('source_asset_id').notNull().references(() => assets.id, { onDelete: 'cascade' }), diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 10cffe8..57803ce 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -17,6 +17,7 @@ export const { integrations, products, assets, + assetOwners, assetDependencies, codePlans, codePlanAssets, diff --git a/lib/types.ts b/lib/types.ts index f6e4948..38d0cf4 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -60,6 +60,13 @@ export interface Product { createdAt: string } +/** Declared owner of an asset (like a code owner) — routing and visibility, not an ACL. */ +export interface AssetOwner { + id: string + name: string + avatarUrl?: string +} + export interface Asset { id: string productId: string @@ -74,6 +81,7 @@ export interface Asset { derivedTechDebtScore?: number /** Open tech-debt work items targeting this asset. */ openDebtCount?: number + owners?: AssetOwner[] repositoryUrl?: string repoPath?: string documentationUrl?: string diff --git a/package.json b/package.json index 40fef35..522c30e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeplans", - "version": "0.3.23", + "version": "0.3.24", "description": "Manage and track coordinated changes across your software architecture.", "author": "Sai Prakash ", "homepage": "https://codeplans.ai",