feat: add project_goals many-to-many join table
Create project_goals join table with composite PK (project_id, goal_id), backfill from existing projects.goal_id, and update the project service to read/write through the join table. Shared types now include goalIds and goals arrays on Project. Legacy goalId column is kept in sync. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
18
packages/db/src/migrations/0011_windy_corsair.sql
Normal file
18
packages/db/src/migrations/0011_windy_corsair.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE "project_goals" (
|
||||||
|
"project_id" uuid NOT NULL,
|
||||||
|
"goal_id" uuid NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "project_goals_project_id_goal_id_pk" PRIMARY KEY("project_id","goal_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "project_goals" ADD CONSTRAINT "project_goals_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "project_goals" ADD CONSTRAINT "project_goals_goal_id_goals_id_fk" FOREIGN KEY ("goal_id") REFERENCES "public"."goals"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "project_goals" ADD CONSTRAINT "project_goals_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "project_goals_project_idx" ON "project_goals" USING btree ("project_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "project_goals_goal_idx" ON "project_goals" USING btree ("goal_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "project_goals_company_idx" ON "project_goals" USING btree ("company_id");--> statement-breakpoint
|
||||||
|
INSERT INTO "project_goals" ("project_id", "goal_id", "company_id")
|
||||||
|
SELECT "id", "goal_id", "company_id" FROM "projects" WHERE "goal_id" IS NOT NULL
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
4068
packages/db/src/migrations/meta/0011_snapshot.json
Normal file
4068
packages/db/src/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,13 @@
|
|||||||
"when": 1771605173513,
|
"when": 1771605173513,
|
||||||
"tag": "0010_stale_justin_hammer",
|
"tag": "0010_stale_justin_hammer",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1771616419708,
|
||||||
|
"tag": "0011_windy_corsair",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ export { agentRuntimeState } from "./agent_runtime_state.js";
|
|||||||
export { agentTaskSessions } from "./agent_task_sessions.js";
|
export { agentTaskSessions } from "./agent_task_sessions.js";
|
||||||
export { agentWakeupRequests } from "./agent_wakeup_requests.js";
|
export { agentWakeupRequests } from "./agent_wakeup_requests.js";
|
||||||
export { projects } from "./projects.js";
|
export { projects } from "./projects.js";
|
||||||
|
export { projectGoals } from "./project_goals.js";
|
||||||
export { goals } from "./goals.js";
|
export { goals } from "./goals.js";
|
||||||
export { issues } from "./issues.js";
|
export { issues } from "./issues.js";
|
||||||
export { issueApprovals } from "./issue_approvals.js";
|
export { issueApprovals } from "./issue_approvals.js";
|
||||||
|
|||||||
21
packages/db/src/schema/project_goals.ts
Normal file
21
packages/db/src/schema/project_goals.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { pgTable, uuid, timestamp, index, primaryKey } from "drizzle-orm/pg-core";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
import { projects } from "./projects.js";
|
||||||
|
import { goals } from "./goals.js";
|
||||||
|
|
||||||
|
export const projectGoals = pgTable(
|
||||||
|
"project_goals",
|
||||||
|
{
|
||||||
|
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
goalId: uuid("goal_id").notNull().references(() => goals.id, { onDelete: "cascade" }),
|
||||||
|
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
pk: primaryKey({ columns: [table.projectId, table.goalId] }),
|
||||||
|
projectIdx: index("project_goals_project_idx").on(table.projectId),
|
||||||
|
goalIdx: index("project_goals_goal_idx").on(table.goalId),
|
||||||
|
companyIdx: index("project_goals_company_idx").on(table.companyId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -49,6 +49,7 @@ export type {
|
|||||||
AdapterEnvironmentTestResult,
|
AdapterEnvironmentTestResult,
|
||||||
AssetImage,
|
AssetImage,
|
||||||
Project,
|
Project,
|
||||||
|
ProjectGoalRef,
|
||||||
Issue,
|
Issue,
|
||||||
IssueComment,
|
IssueComment,
|
||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export type {
|
|||||||
AdapterEnvironmentTestResult,
|
AdapterEnvironmentTestResult,
|
||||||
} from "./agent.js";
|
} from "./agent.js";
|
||||||
export type { AssetImage } from "./asset.js";
|
export type { AssetImage } from "./asset.js";
|
||||||
export type { Project } from "./project.js";
|
export type { Project, ProjectGoalRef } from "./project.js";
|
||||||
export type {
|
export type {
|
||||||
Issue,
|
Issue,
|
||||||
IssueComment,
|
IssueComment,
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import type { ProjectStatus } from "../constants.js";
|
import type { ProjectStatus } from "../constants.js";
|
||||||
|
|
||||||
|
export interface ProjectGoalRef {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
|
/** @deprecated Use goalIds / goals instead */
|
||||||
goalId: string | null;
|
goalId: string | null;
|
||||||
|
goalIds: string[];
|
||||||
|
goals: ProjectGoalRef[];
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
status: ProjectStatus;
|
status: ProjectStatus;
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { z } from "zod";
|
|||||||
import { PROJECT_STATUSES } from "../constants.js";
|
import { PROJECT_STATUSES } from "../constants.js";
|
||||||
|
|
||||||
export const createProjectSchema = z.object({
|
export const createProjectSchema = z.object({
|
||||||
|
/** @deprecated Use goalIds instead */
|
||||||
goalId: z.string().uuid().optional().nullable(),
|
goalId: z.string().uuid().optional().nullable(),
|
||||||
|
goalIds: z.array(z.string().uuid()).optional(),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
status: z.enum(PROJECT_STATUSES).optional().default("backlog"),
|
status: z.enum(PROJECT_STATUSES).optional().default("backlog"),
|
||||||
|
|||||||
@@ -1,32 +1,143 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclip/db";
|
import type { Db } from "@paperclip/db";
|
||||||
import { projects } from "@paperclip/db";
|
import { projects, projectGoals, goals } from "@paperclip/db";
|
||||||
|
import type { ProjectGoalRef } from "@paperclip/shared";
|
||||||
|
|
||||||
|
type ProjectRow = typeof projects.$inferSelect;
|
||||||
|
|
||||||
|
interface ProjectWithGoals extends ProjectRow {
|
||||||
|
goalIds: string[];
|
||||||
|
goals: ProjectGoalRef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Batch-load goal refs for a set of projects. */
|
||||||
|
async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals[]> {
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
|
||||||
|
const projectIds = rows.map((r) => r.id);
|
||||||
|
|
||||||
|
// Fetch join rows + goal titles in one query
|
||||||
|
const links = await db
|
||||||
|
.select({
|
||||||
|
projectId: projectGoals.projectId,
|
||||||
|
goalId: projectGoals.goalId,
|
||||||
|
goalTitle: goals.title,
|
||||||
|
})
|
||||||
|
.from(projectGoals)
|
||||||
|
.innerJoin(goals, eq(projectGoals.goalId, goals.id))
|
||||||
|
.where(inArray(projectGoals.projectId, projectIds));
|
||||||
|
|
||||||
|
const map = new Map<string, ProjectGoalRef[]>();
|
||||||
|
for (const link of links) {
|
||||||
|
let arr = map.get(link.projectId);
|
||||||
|
if (!arr) {
|
||||||
|
arr = [];
|
||||||
|
map.set(link.projectId, arr);
|
||||||
|
}
|
||||||
|
arr.push({ id: link.goalId, title: link.goalTitle });
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map((r) => {
|
||||||
|
const g = map.get(r.id) ?? [];
|
||||||
|
return { ...r, goalIds: g.map((x) => x.id), goals: g };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sync the project_goals join table for a single project. */
|
||||||
|
async function syncGoalLinks(db: Db, projectId: string, companyId: string, goalIds: string[]) {
|
||||||
|
// Delete existing links
|
||||||
|
await db.delete(projectGoals).where(eq(projectGoals.projectId, projectId));
|
||||||
|
|
||||||
|
// Insert new links
|
||||||
|
if (goalIds.length > 0) {
|
||||||
|
await db.insert(projectGoals).values(
|
||||||
|
goalIds.map((goalId) => ({ projectId, goalId, companyId })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve goalIds from input, handling the legacy goalId field. */
|
||||||
|
function resolveGoalIds(data: { goalIds?: string[]; goalId?: string | null }): string[] | undefined {
|
||||||
|
if (data.goalIds !== undefined) return data.goalIds;
|
||||||
|
if (data.goalId !== undefined) {
|
||||||
|
return data.goalId ? [data.goalId] : [];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function projectService(db: Db) {
|
export function projectService(db: Db) {
|
||||||
return {
|
return {
|
||||||
list: (companyId: string) => db.select().from(projects).where(eq(projects.companyId, companyId)),
|
list: async (companyId: string): Promise<ProjectWithGoals[]> => {
|
||||||
|
const rows = await db.select().from(projects).where(eq(projects.companyId, companyId));
|
||||||
|
return attachGoals(db, rows);
|
||||||
|
},
|
||||||
|
|
||||||
getById: (id: string) =>
|
getById: async (id: string): Promise<ProjectWithGoals | null> => {
|
||||||
db
|
const row = await db
|
||||||
.select()
|
.select()
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.where(eq(projects.id, id))
|
.where(eq(projects.id, id))
|
||||||
.then((rows) => rows[0] ?? null),
|
.then((rows) => rows[0] ?? null);
|
||||||
|
if (!row) return null;
|
||||||
|
const [enriched] = await attachGoals(db, [row]);
|
||||||
|
return enriched;
|
||||||
|
},
|
||||||
|
|
||||||
create: (companyId: string, data: Omit<typeof projects.$inferInsert, "companyId">) =>
|
create: async (
|
||||||
db
|
companyId: string,
|
||||||
|
data: Omit<typeof projects.$inferInsert, "companyId"> & { goalIds?: string[] },
|
||||||
|
): Promise<ProjectWithGoals> => {
|
||||||
|
const { goalIds: inputGoalIds, ...projectData } = data;
|
||||||
|
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
||||||
|
|
||||||
|
// Also write goalId to the legacy column (first goal or null)
|
||||||
|
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
|
||||||
|
|
||||||
|
const row = await db
|
||||||
.insert(projects)
|
.insert(projects)
|
||||||
.values({ ...data, companyId })
|
.values({ ...projectData, goalId: legacyGoalId, companyId })
|
||||||
.returning()
|
.returning()
|
||||||
.then((rows) => rows[0]),
|
.then((rows) => rows[0]);
|
||||||
|
|
||||||
update: (id: string, data: Partial<typeof projects.$inferInsert>) =>
|
if (ids && ids.length > 0) {
|
||||||
db
|
await syncGoalLinks(db, row.id, companyId, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [enriched] = await attachGoals(db, [row]);
|
||||||
|
return enriched;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (
|
||||||
|
id: string,
|
||||||
|
data: Partial<typeof projects.$inferInsert> & { goalIds?: string[] },
|
||||||
|
): Promise<ProjectWithGoals | null> => {
|
||||||
|
const { goalIds: inputGoalIds, ...projectData } = data;
|
||||||
|
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
||||||
|
|
||||||
|
// Keep legacy goalId column in sync
|
||||||
|
const updates: Partial<typeof projects.$inferInsert> = {
|
||||||
|
...projectData,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (ids !== undefined) {
|
||||||
|
updates.goalId = ids.length > 0 ? ids[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await db
|
||||||
.update(projects)
|
.update(projects)
|
||||||
.set({ ...data, updatedAt: new Date() })
|
.set(updates)
|
||||||
.where(eq(projects.id, id))
|
.where(eq(projects.id, id))
|
||||||
.returning()
|
.returning()
|
||||||
.then((rows) => rows[0] ?? null),
|
.then((rows) => rows[0] ?? null);
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
if (ids !== undefined) {
|
||||||
|
await syncGoalLinks(db, id, row.companyId, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [enriched] = await attachGoals(db, [row]);
|
||||||
|
return enriched;
|
||||||
|
},
|
||||||
|
|
||||||
remove: (id: string) =>
|
remove: (id: string) =>
|
||||||
db
|
db
|
||||||
|
|||||||
Reference in New Issue
Block a user