mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
Add folders schema and backend API
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
3a727bf780
commit
a072fafbb8
24 files changed
+823
-1
No files matched your search
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE "folders" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"color" text,
|
||||
"position" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "company_skills" ADD COLUMN "folder_id" uuid;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "routines" ADD COLUMN "folder_id" uuid;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "folders" ADD CONSTRAINT "folders_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "company_skills" ADD CONSTRAINT "company_skills_folder_id_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."folders"("id") ON DELETE set null ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "routines" ADD CONSTRAINT "routines_folder_id_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."folders"("id") ON DELETE set null ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "folders_company_kind_position_idx" ON "folders" USING btree ("company_id","kind","position","name");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "folders_company_kind_name_uq" ON "folders" USING btree ("company_id","kind","name");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "company_skills_company_folder_idx" ON "company_skills" USING btree ("company_id","folder_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "routines_company_folder_idx" ON "routines" USING btree ("company_id","folder_id");
|
||||
@@ -1184,6 +1184,13 @@
|
||||
"when": 1784037600000,
|
||||
"tag": "0170_company_skill_policies",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 171,
|
||||
"version": "7",
|
||||
"when": 1784037601000,
|
||||
"tag": "0171_folders",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,12 +13,14 @@ import type { CompanySkillFileInventoryEntry, CompanySkillSharingScope } from "@
|
||||
import { agents } from "./agents.js";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { folders } from "./folders.js";
|
||||
|
||||
export const companySkills = pgTable(
|
||||
"company_skills",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
folderId: uuid("folder_id").references(() => folders.id, { onDelete: "set null" }),
|
||||
key: text("key").notNull(),
|
||||
slug: text("slug").notNull(),
|
||||
name: text("name").notNull(),
|
||||
@@ -51,6 +53,7 @@ export const companySkills = pgTable(
|
||||
(table) => ({
|
||||
companyKeyUniqueIdx: uniqueIndex("company_skills_company_key_idx").on(table.companyId, table.key),
|
||||
companyNameIdx: index("company_skills_company_name_idx").on(table.companyId, table.name),
|
||||
companyFolderIdx: index("company_skills_company_folder_idx").on(table.companyId, table.folderId),
|
||||
companyCategoriesIdx: index("company_skills_company_categories_idx").using("gin", table.categories),
|
||||
companySharingScopeIdx: index("company_skills_company_sharing_scope_idx").on(table.companyId, table.sharingScope),
|
||||
companyCurrentVersionIdx: index("company_skills_company_current_version_idx").on(table.companyId, table.currentVersionId),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
index,
|
||||
integer,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import type { FolderKind } from "@paperclipai/shared";
|
||||
|
||||
export const folders = pgTable(
|
||||
"folders",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
kind: text("kind").$type<FolderKind>().notNull(),
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
position: integer("position").notNull().default(0),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyKindPositionIdx: index("folders_company_kind_position_idx").on(
|
||||
table.companyId,
|
||||
table.kind,
|
||||
table.position,
|
||||
table.name,
|
||||
),
|
||||
companyKindNameUniqueIdx: uniqueIndex("folders_company_kind_name_uq").on(
|
||||
table.companyId,
|
||||
table.kind,
|
||||
table.name,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -35,6 +35,7 @@ export { workspaceOperations } from "./workspace_operations.js";
|
||||
export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
|
||||
export { projectGoals } from "./project_goals.js";
|
||||
export { goals } from "./goals.js";
|
||||
export { folders } from "./folders.js";
|
||||
export { issues } from "./issues.js";
|
||||
export { issueWatchdogs } from "./issue_watchdogs.js";
|
||||
export { issuePlanDecompositions } from "./issue_plan_decompositions.js";
|
||||
|
||||
@@ -17,6 +17,7 @@ import { issues } from "./issues.js";
|
||||
import { projects } from "./projects.js";
|
||||
import { goals } from "./goals.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { folders } from "./folders.js";
|
||||
import type { RoutineEnvConfig, RoutineRevisionSnapshotV1, RoutineVariable } from "@paperclipai/shared";
|
||||
|
||||
export const routines = pgTable(
|
||||
@@ -25,6 +26,7 @@ export const routines = pgTable(
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
projectId: uuid("project_id").references(() => projects.id, { onDelete: "cascade" }),
|
||||
folderId: uuid("folder_id").references(() => folders.id, { onDelete: "set null" }),
|
||||
goalId: uuid("goal_id").references(() => goals.id, { onDelete: "set null" }),
|
||||
parentIssueId: uuid("parent_issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||
title: text("title").notNull(),
|
||||
@@ -56,6 +58,7 @@ export const routines = pgTable(
|
||||
companyStatusIdx: index("routines_company_status_idx").on(table.companyId, table.status),
|
||||
companyAssigneeIdx: index("routines_company_assignee_idx").on(table.companyId, table.assigneeAgentId),
|
||||
companyProjectIdx: index("routines_company_project_idx").on(table.companyId, table.projectId),
|
||||
companyFolderIdx: index("routines_company_folder_idx").on(table.companyId, table.folderId),
|
||||
companyResponsibleUserIdx: index("routines_company_responsible_user_idx").on(table.companyId, table.responsibleUserId),
|
||||
companyOriginIdx: index("routines_company_origin_idx").on(table.companyId, table.originKind, table.originId),
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,10 @@ export const API_PREFIX = "/api";
|
||||
export const API = {
|
||||
health: `${API_PREFIX}/health`,
|
||||
companies: `${API_PREFIX}/companies`,
|
||||
companyFolders: `${API_PREFIX}/companies/:companyId/folders`,
|
||||
companyFolder: `${API_PREFIX}/companies/:companyId/folders/:folderId`,
|
||||
companyFolderMove: `${API_PREFIX}/companies/:companyId/folders/:folderId/move`,
|
||||
companyFolderItemMove: `${API_PREFIX}/companies/:companyId/folders/items/move`,
|
||||
agents: `${API_PREFIX}/agents`,
|
||||
projects: `${API_PREFIX}/projects`,
|
||||
environments: `${API_PREFIX}/environments`,
|
||||
|
||||
@@ -2079,12 +2079,36 @@ export type {
|
||||
} from "./environment-support.js";
|
||||
|
||||
export type { AdapterRegistryEntry } from "./types/adapter-registry.js";
|
||||
export type {
|
||||
FolderKind,
|
||||
Folder,
|
||||
FolderListItem,
|
||||
FolderListResult,
|
||||
CreateFolderRequest,
|
||||
UpdateFolderRequest,
|
||||
MoveFolderRequest,
|
||||
MoveFolderItemRequest,
|
||||
} from "./types/folder.js";
|
||||
|
||||
export {
|
||||
adapterRegistryEntrySchema,
|
||||
adapterRegistrySchema,
|
||||
type AdapterRegistryEntryParsed,
|
||||
} from "./validators/adapter-registry.js";
|
||||
export {
|
||||
folderKindSchema,
|
||||
folderSchema,
|
||||
folderListItemSchema,
|
||||
folderListResultSchema,
|
||||
createFolderSchema,
|
||||
updateFolderSchema,
|
||||
moveFolderSchema,
|
||||
moveFolderItemSchema,
|
||||
type CreateFolder,
|
||||
type UpdateFolder,
|
||||
type MoveFolder,
|
||||
type MoveFolderItem,
|
||||
} from "./validators/folder.js";
|
||||
|
||||
export {
|
||||
environmentCustomImageTemplateKindSchema,
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface CompanySkillVersionFileInventoryEntry extends CompanySkillFileI
|
||||
export interface CompanySkill {
|
||||
id: string;
|
||||
companyId: string;
|
||||
folderId?: string | null;
|
||||
key: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
@@ -67,6 +68,7 @@ export interface CompanySkill {
|
||||
export interface CompanySkillListItem {
|
||||
id: string;
|
||||
companyId: string;
|
||||
folderId?: string | null;
|
||||
key: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export type FolderKind = "routine" | "skill";
|
||||
|
||||
export interface Folder {
|
||||
id: string;
|
||||
companyId: string;
|
||||
kind: FolderKind;
|
||||
name: string;
|
||||
color: string | null;
|
||||
position: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface FolderListItem extends Folder {
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
export interface FolderListResult {
|
||||
kind: FolderKind;
|
||||
folders: FolderListItem[];
|
||||
allCount: number;
|
||||
unfiledCount: number;
|
||||
}
|
||||
|
||||
export interface CreateFolderRequest {
|
||||
kind: FolderKind;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
position?: number | null;
|
||||
}
|
||||
|
||||
export interface UpdateFolderRequest {
|
||||
name?: string;
|
||||
color?: string | null;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface MoveFolderRequest {
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface MoveFolderItemRequest {
|
||||
kind: FolderKind;
|
||||
itemId: string;
|
||||
folderId?: string | null;
|
||||
}
|
||||
@@ -161,6 +161,16 @@ export type {
|
||||
CompanySkillInstallCatalogRequest,
|
||||
CompanySkillInstallCatalogResult,
|
||||
} from "./company-skill.js";
|
||||
export type {
|
||||
FolderKind,
|
||||
Folder,
|
||||
FolderListItem,
|
||||
FolderListResult,
|
||||
CreateFolderRequest,
|
||||
UpdateFolderRequest,
|
||||
MoveFolderRequest,
|
||||
MoveFolderItemRequest,
|
||||
} from "./folder.js";
|
||||
export type {
|
||||
CatalogTeamKind,
|
||||
CatalogTeamTrustLevel,
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface Routine {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
folderId?: string | null;
|
||||
goalId: string | null;
|
||||
parentIssueId: string | null;
|
||||
title: string;
|
||||
|
||||
@@ -20,6 +20,7 @@ export const companySkillVersionFileInventoryEntrySchema = companySkillFileInven
|
||||
export const companySkillSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
folderId: z.string().uuid().nullable().optional(),
|
||||
key: z.string().min(1),
|
||||
slug: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const folderKindSchema = z.enum(["routine", "skill"]);
|
||||
|
||||
export const folderSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
kind: folderKindSchema,
|
||||
name: z.string().min(1),
|
||||
color: z.string().nullable(),
|
||||
position: z.number().int(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
export const folderListItemSchema = folderSchema.extend({
|
||||
itemCount: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const folderListResultSchema = z.object({
|
||||
kind: folderKindSchema,
|
||||
folders: z.array(folderListItemSchema),
|
||||
allCount: z.number().int().nonnegative(),
|
||||
unfiledCount: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const createFolderSchema = z.object({
|
||||
kind: folderKindSchema,
|
||||
name: z.string().trim().min(1).max(120),
|
||||
color: z.string().trim().min(1).max(80).optional().nullable(),
|
||||
position: z.number().int().min(0).optional().nullable(),
|
||||
});
|
||||
|
||||
export const updateFolderSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120).optional(),
|
||||
color: z.string().trim().min(1).max(80).optional().nullable(),
|
||||
position: z.number().int().min(0).optional(),
|
||||
}).refine((value) => Object.keys(value).length > 0, {
|
||||
message: "At least one folder field is required",
|
||||
});
|
||||
|
||||
export const moveFolderSchema = z.object({
|
||||
position: z.number().int().min(0),
|
||||
});
|
||||
|
||||
export const moveFolderItemSchema = z.object({
|
||||
kind: folderKindSchema,
|
||||
itemId: z.string().uuid(),
|
||||
folderId: z.string().uuid().optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateFolder = z.infer<typeof createFolderSchema>;
|
||||
export type UpdateFolder = z.infer<typeof updateFolderSchema>;
|
||||
export type MoveFolder = z.infer<typeof moveFolderSchema>;
|
||||
export type MoveFolderItem = z.infer<typeof moveFolderItemSchema>;
|
||||
@@ -194,6 +194,20 @@ export {
|
||||
type CompanySkillInstallUpdate,
|
||||
type CompanySkillReset,
|
||||
} from "./company-skill.js";
|
||||
export {
|
||||
folderKindSchema,
|
||||
folderSchema,
|
||||
folderListItemSchema,
|
||||
folderListResultSchema,
|
||||
createFolderSchema,
|
||||
updateFolderSchema,
|
||||
moveFolderSchema,
|
||||
moveFolderItemSchema,
|
||||
type CreateFolder,
|
||||
type UpdateFolder,
|
||||
type MoveFolder,
|
||||
type MoveFolderItem,
|
||||
} from "./folder.js";
|
||||
export {
|
||||
catalogTeamKindSchema,
|
||||
catalogTeamTrustLevelSchema,
|
||||
|
||||
@@ -61,6 +61,7 @@ export const routineVariableSchema = z.object({
|
||||
|
||||
export const createRoutineSchema = z.object({
|
||||
projectId: z.string().uuid().optional().nullable(),
|
||||
folderId: z.string().uuid().optional().nullable(),
|
||||
goalId: z.string().uuid().optional().nullable(),
|
||||
parentIssueId: z.string().uuid().optional().nullable(),
|
||||
title: z.string().trim().min(1).max(200),
|
||||
@@ -85,6 +86,7 @@ export const routineRevisionSnapshotRoutineV1Schema = z.object({
|
||||
id: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
projectId: z.string().uuid().nullable(),
|
||||
folderId: z.string().uuid().nullable().optional(),
|
||||
goalId: z.string().uuid().nullable(),
|
||||
parentIssueId: z.string().uuid().nullable(),
|
||||
title: z.string().trim().min(1).max(200),
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
companies,
|
||||
companySkills,
|
||||
createDb,
|
||||
folders,
|
||||
routines,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { folderService } from "../services/folders.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
describeEmbeddedPostgres("folder service", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-folders-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(companySkills);
|
||||
await db.delete(routines);
|
||||
await db.delete(folders);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedCompany() {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
});
|
||||
return companyId;
|
||||
}
|
||||
|
||||
async function seedRoutine(companyId: string, title: string, folderId?: string | null) {
|
||||
const [routine] = await db
|
||||
.insert(routines)
|
||||
.values({
|
||||
companyId,
|
||||
title,
|
||||
folderId: folderId ?? null,
|
||||
responsibleUserId: "responsible-user",
|
||||
})
|
||||
.returning();
|
||||
return routine!;
|
||||
}
|
||||
|
||||
async function seedSkill(companyId: string, slug: string, folderId?: string | null) {
|
||||
const [skill] = await db
|
||||
.insert(companySkills)
|
||||
.values({
|
||||
companyId,
|
||||
folderId: folderId ?? null,
|
||||
key: `company/${companyId}/${slug}`,
|
||||
slug,
|
||||
name: slug,
|
||||
markdown: `# ${slug}`,
|
||||
})
|
||||
.returning();
|
||||
return skill!;
|
||||
}
|
||||
|
||||
it("creates, updates, reorders, and lists routine folders with counts", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
|
||||
const reporting = await svc.create(companyId, {
|
||||
kind: "routine",
|
||||
name: "Reporting",
|
||||
color: "green",
|
||||
});
|
||||
const cleanup = await svc.create(companyId, {
|
||||
kind: "routine",
|
||||
name: "Cleanup",
|
||||
color: null,
|
||||
});
|
||||
await seedRoutine(companyId, "Filed", reporting.id);
|
||||
await seedRoutine(companyId, "Unfiled");
|
||||
|
||||
const renamed = await svc.update(companyId, cleanup.id, { name: "Ops", color: "cyan" });
|
||||
expect(renamed).toMatchObject({ id: cleanup.id, name: "Ops", color: "cyan" });
|
||||
|
||||
const movedFolder = await svc.moveFolder(companyId, reporting.id, { position: 10 });
|
||||
expect(movedFolder).toMatchObject({ id: reporting.id, position: 10 });
|
||||
|
||||
const listed = await svc.list(companyId, "routine");
|
||||
expect(listed.allCount).toBe(2);
|
||||
expect(listed.unfiledCount).toBe(1);
|
||||
expect(listed.folders).toEqual([
|
||||
expect.objectContaining({ id: cleanup.id, name: "Ops", itemCount: 0 }),
|
||||
expect.objectContaining({ id: reporting.id, name: "Reporting", itemCount: 1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves routines and skills to folders and back to virtual Unfiled", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
const routineFolder = await svc.create(companyId, { kind: "routine", name: "Reports" });
|
||||
const skillFolder = await svc.create(companyId, { kind: "skill", name: "Runtime" });
|
||||
const routine = await seedRoutine(companyId, "Daily report");
|
||||
const skill = await seedSkill(companyId, "review");
|
||||
|
||||
await expect(svc.moveItem(companyId, {
|
||||
kind: "routine",
|
||||
itemId: routine.id,
|
||||
folderId: routineFolder.id,
|
||||
})).resolves.toEqual({ kind: "routine", itemId: routine.id, folderId: routineFolder.id });
|
||||
await expect(svc.moveItem(companyId, {
|
||||
kind: "skill",
|
||||
itemId: skill.id,
|
||||
folderId: skillFolder.id,
|
||||
})).resolves.toEqual({ kind: "skill", itemId: skill.id, folderId: skillFolder.id });
|
||||
|
||||
await expect(svc.moveItem(companyId, {
|
||||
kind: "routine",
|
||||
itemId: routine.id,
|
||||
folderId: null,
|
||||
})).resolves.toEqual({ kind: "routine", itemId: routine.id, folderId: null });
|
||||
|
||||
const [updatedRoutine] = await db.select().from(routines).where(eq(routines.id, routine.id));
|
||||
const [updatedSkill] = await db.select().from(companySkills).where(eq(companySkills.id, skill.id));
|
||||
expect(updatedRoutine?.folderId).toBeNull();
|
||||
expect(updatedSkill?.folderId).toBe(skillFolder.id);
|
||||
});
|
||||
|
||||
it("rejects moving an item into a folder of the wrong kind", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
const skillFolder = await svc.create(companyId, { kind: "skill", name: "Runtime" });
|
||||
const routine = await seedRoutine(companyId, "Daily report");
|
||||
|
||||
await expect(svc.moveItem(companyId, {
|
||||
kind: "routine",
|
||||
itemId: routine.id,
|
||||
folderId: skillFolder.id,
|
||||
})).rejects.toMatchObject({
|
||||
status: 422,
|
||||
message: "Folder kind must match item kind",
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes folders without deleting contents by moving items to Unfiled", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
const folder = await svc.create(companyId, { kind: "routine", name: "Reports" });
|
||||
const routine = await seedRoutine(companyId, "Daily report", folder.id);
|
||||
|
||||
const deleted = await svc.deleteFolder(companyId, folder.id);
|
||||
expect(deleted).toMatchObject({ id: folder.id, name: "Reports" });
|
||||
|
||||
const [updatedRoutine] = await db.select().from(routines).where(eq(routines.id, routine.id));
|
||||
expect(updatedRoutine?.folderId).toBeNull();
|
||||
expect(await db.select().from(folders).where(eq(folders.id, folder.id))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import { companyRoutes } from "./routes/companies.js";
|
||||
import { companySkillRoutes } from "./routes/company-skills.js";
|
||||
import { companySkillPolicyRoutes } from "./routes/company-skill-policy.js";
|
||||
import { builtInAgentRoutes } from "./routes/built-in-agents.js";
|
||||
import { folderRoutes } from "./routes/folders.js";
|
||||
import { teamsCatalogRoutes } from "./routes/teams-catalog.js";
|
||||
import { agentRoutes } from "./routes/agents.js";
|
||||
import { projectRoutes } from "./routes/projects.js";
|
||||
@@ -235,6 +236,7 @@ export async function createApp(
|
||||
api.use(openApiRoutes());
|
||||
api.use("/companies", companyRoutes(db, opts.storageService));
|
||||
api.use(llmRoutes(db));
|
||||
api.use(folderRoutes(db));
|
||||
api.use(companySkillRoutes(db));
|
||||
api.use(companySkillPolicyRoutes(db));
|
||||
api.use(builtInAgentRoutes(db));
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
createFolderSchema,
|
||||
folderKindSchema,
|
||||
moveFolderItemSchema,
|
||||
moveFolderSchema,
|
||||
updateFolderSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { badRequest } from "../errors.js";
|
||||
import { folderService, logActivity } from "../services/index.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
export function folderRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = folderService(db);
|
||||
|
||||
function parseKind(value: unknown) {
|
||||
const result = folderKindSchema.safeParse(value);
|
||||
if (!result.success) throw badRequest("Folder kind query parameter is required");
|
||||
return result.data;
|
||||
}
|
||||
|
||||
router.get("/companies/:companyId/folders", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
res.json(await svc.list(companyId, parseKind(req.query.kind)));
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/folders", validate(createFolderSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const created = await svc.create(companyId, req.body);
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "folder.created",
|
||||
entityType: "folder",
|
||||
entityId: created.id,
|
||||
details: { kind: created.kind, name: created.name, position: created.position },
|
||||
});
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
router.patch("/companies/:companyId/folders/:folderId", validate(updateFolderSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const folderId = req.params.folderId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const updated = await svc.update(companyId, folderId, req.body);
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: "Folder not found" });
|
||||
return;
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "folder.updated",
|
||||
entityType: "folder",
|
||||
entityId: updated.id,
|
||||
details: { kind: updated.kind, name: updated.name, position: updated.position },
|
||||
});
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/folders/:folderId/move", validate(moveFolderSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const folderId = req.params.folderId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const updated = await svc.moveFolder(companyId, folderId, req.body);
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: "Folder not found" });
|
||||
return;
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "folder.moved",
|
||||
entityType: "folder",
|
||||
entityId: updated.id,
|
||||
details: { kind: updated.kind, position: updated.position },
|
||||
});
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/folders/items/move", validate(moveFolderItemSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const moved = await svc.moveItem(companyId, req.body);
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "folder.item_moved",
|
||||
entityType: req.body.kind === "routine" ? "routine" : "company_skill",
|
||||
entityId: moved.itemId,
|
||||
details: { kind: moved.kind, folderId: moved.folderId },
|
||||
});
|
||||
res.json(moved);
|
||||
});
|
||||
|
||||
router.delete("/companies/:companyId/folders/:folderId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const folderId = req.params.folderId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const deleted = await svc.deleteFolder(companyId, folderId);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: "Folder not found" });
|
||||
return;
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "folder.deleted",
|
||||
entityType: "folder",
|
||||
entityId: deleted.id,
|
||||
details: { kind: deleted.kind, name: deleted.name },
|
||||
});
|
||||
res.json({ deleted });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export { companyRoutes } from "./companies.js";
|
||||
export { companySkillRoutes } from "./company-skills.js";
|
||||
export { companySkillPolicyRoutes } from "./company-skill-policy.js";
|
||||
export { builtInAgentRoutes } from "./built-in-agents.js";
|
||||
export { folderRoutes } from "./folders.js";
|
||||
export { teamsCatalogRoutes } from "./teams-catalog.js";
|
||||
export { agentRoutes } from "./agents.js";
|
||||
export { projectRoutes } from "./projects.js";
|
||||
|
||||
@@ -118,6 +118,7 @@ type CompanySkillListDbRow = Pick<
|
||||
CompanySkillRow,
|
||||
| "id"
|
||||
| "companyId"
|
||||
| "folderId"
|
||||
| "key"
|
||||
| "slug"
|
||||
| "name"
|
||||
@@ -150,6 +151,7 @@ type CompanySkillListRow = Pick<
|
||||
CompanySkill,
|
||||
| "id"
|
||||
| "companyId"
|
||||
| "folderId"
|
||||
| "key"
|
||||
| "slug"
|
||||
| "name"
|
||||
@@ -375,6 +377,7 @@ function selectCompanySkillColumns() {
|
||||
return {
|
||||
id: companySkills.id,
|
||||
companyId: companySkills.companyId,
|
||||
folderId: companySkills.folderId,
|
||||
key: companySkills.key,
|
||||
slug: companySkills.slug,
|
||||
name: companySkills.name,
|
||||
@@ -2518,6 +2521,7 @@ function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount:
|
||||
return {
|
||||
id: skill.id,
|
||||
companyId: skill.companyId,
|
||||
folderId: skill.folderId,
|
||||
key: skill.key,
|
||||
slug: skill.slug,
|
||||
name: skill.name,
|
||||
@@ -2783,6 +2787,7 @@ export function companySkillService(db: Db) {
|
||||
.select({
|
||||
id: companySkills.id,
|
||||
companyId: companySkills.companyId,
|
||||
folderId: companySkills.folderId,
|
||||
key: companySkills.key,
|
||||
slug: companySkills.slug,
|
||||
name: companySkills.name,
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { and, asc, eq, max, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companySkills, folders, routines } from "@paperclipai/db";
|
||||
import type {
|
||||
CreateFolder,
|
||||
Folder,
|
||||
FolderKind,
|
||||
FolderListResult,
|
||||
MoveFolder,
|
||||
MoveFolderItem,
|
||||
UpdateFolder,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
|
||||
type FolderRow = typeof folders.$inferSelect;
|
||||
|
||||
function mapFolder(row: FolderRow): Folder {
|
||||
return {
|
||||
...row,
|
||||
color: row.color ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeName(name: string) {
|
||||
return name.trim();
|
||||
}
|
||||
|
||||
function normalizeColor(color: string | null | undefined) {
|
||||
if (color === undefined) return undefined;
|
||||
const trimmed = color?.trim() ?? "";
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function folderService(db: Db) {
|
||||
async function getFolder(companyId: string, folderId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.companyId, companyId), eq(folders.id, folderId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function assertNoNameConflict(companyId: string, kind: FolderKind, name: string, excludeFolderId?: string) {
|
||||
const existing = await db
|
||||
.select({ id: folders.id })
|
||||
.from(folders)
|
||||
.where(and(eq(folders.companyId, companyId), eq(folders.kind, kind), eq(folders.name, name)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing && existing.id !== excludeFolderId) {
|
||||
throw conflict("Folder name already exists for this kind");
|
||||
}
|
||||
}
|
||||
|
||||
async function nextPosition(companyId: string, kind: FolderKind) {
|
||||
const row = await db
|
||||
.select({ value: max(folders.position) })
|
||||
.from(folders)
|
||||
.where(and(eq(folders.companyId, companyId), eq(folders.kind, kind)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return Number(row?.value ?? -1) + 1;
|
||||
}
|
||||
|
||||
async function routineCounts(companyId: string) {
|
||||
return db
|
||||
.select({
|
||||
folderId: routines.folderId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(routines)
|
||||
.where(eq(routines.companyId, companyId))
|
||||
.groupBy(routines.folderId);
|
||||
}
|
||||
|
||||
async function skillCounts(companyId: string) {
|
||||
return db
|
||||
.select({
|
||||
folderId: companySkills.folderId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(companySkills)
|
||||
.where(eq(companySkills.companyId, companyId))
|
||||
.groupBy(companySkills.folderId);
|
||||
}
|
||||
|
||||
async function list(companyId: string, kind: FolderKind): Promise<FolderListResult> {
|
||||
const [folderRows, countRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.companyId, companyId), eq(folders.kind, kind)))
|
||||
.orderBy(asc(folders.position), asc(folders.name), asc(folders.id)),
|
||||
kind === "routine" ? routineCounts(companyId) : skillCounts(companyId),
|
||||
]);
|
||||
|
||||
const countsByFolderId = new Map<string | null, number>();
|
||||
for (const row of countRows) {
|
||||
countsByFolderId.set(row.folderId ?? null, Number(row.count ?? 0));
|
||||
}
|
||||
|
||||
return {
|
||||
kind,
|
||||
folders: folderRows.map((row) => ({
|
||||
...mapFolder(row),
|
||||
itemCount: countsByFolderId.get(row.id) ?? 0,
|
||||
})),
|
||||
allCount: Array.from(countsByFolderId.values()).reduce((sum, count) => sum + count, 0),
|
||||
unfiledCount: countsByFolderId.get(null) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function create(companyId: string, input: CreateFolder): Promise<Folder> {
|
||||
const name = normalizeName(input.name);
|
||||
await assertNoNameConflict(companyId, input.kind, name);
|
||||
const position = input.position ?? await nextPosition(companyId, input.kind);
|
||||
const row = await db
|
||||
.insert(folders)
|
||||
.values({
|
||||
companyId,
|
||||
kind: input.kind,
|
||||
name,
|
||||
color: normalizeColor(input.color) ?? null,
|
||||
position,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Failed to create folder");
|
||||
return mapFolder(row);
|
||||
}
|
||||
|
||||
async function update(companyId: string, folderId: string, patch: UpdateFolder): Promise<Folder | null> {
|
||||
const existing = await getFolder(companyId, folderId);
|
||||
if (!existing) return null;
|
||||
const nextName = patch.name === undefined ? existing.name : normalizeName(patch.name);
|
||||
if (nextName !== existing.name) {
|
||||
await assertNoNameConflict(companyId, existing.kind, nextName, folderId);
|
||||
}
|
||||
const row = await db
|
||||
.update(folders)
|
||||
.set({
|
||||
name: nextName,
|
||||
color: patch.color === undefined ? existing.color : normalizeColor(patch.color),
|
||||
position: patch.position ?? existing.position,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(folders.companyId, companyId), eq(folders.id, folderId)))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? mapFolder(row) : null;
|
||||
}
|
||||
|
||||
async function moveFolder(companyId: string, folderId: string, input: MoveFolder): Promise<Folder | null> {
|
||||
const row = await db
|
||||
.update(folders)
|
||||
.set({ position: input.position, updatedAt: new Date() })
|
||||
.where(and(eq(folders.companyId, companyId), eq(folders.id, folderId)))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? mapFolder(row) : null;
|
||||
}
|
||||
|
||||
async function deleteFolder(companyId: string, folderId: string): Promise<Folder | null> {
|
||||
return db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
const folder = await txDb
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.companyId, companyId), eq(folders.id, folderId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!folder) return null;
|
||||
|
||||
if (folder.kind === "routine") {
|
||||
await txDb
|
||||
.update(routines)
|
||||
.set({ folderId: null, updatedAt: new Date() })
|
||||
.where(and(eq(routines.companyId, companyId), eq(routines.folderId, folderId)));
|
||||
} else {
|
||||
await txDb
|
||||
.update(companySkills)
|
||||
.set({ folderId: null, updatedAt: new Date() })
|
||||
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.folderId, folderId)));
|
||||
}
|
||||
|
||||
await txDb
|
||||
.delete(folders)
|
||||
.where(and(eq(folders.companyId, companyId), eq(folders.id, folderId)));
|
||||
return mapFolder(folder);
|
||||
});
|
||||
}
|
||||
|
||||
async function assertTargetFolder(companyId: string, kind: FolderKind, folderId: string | null | undefined) {
|
||||
if (!folderId) return null;
|
||||
const folder = await getFolder(companyId, folderId);
|
||||
if (!folder) throw notFound("Folder not found");
|
||||
if (folder.kind !== kind) throw unprocessable("Folder kind must match item kind");
|
||||
return folder;
|
||||
}
|
||||
|
||||
async function moveItem(companyId: string, input: MoveFolderItem) {
|
||||
const targetFolder = await assertTargetFolder(companyId, input.kind, input.folderId ?? null);
|
||||
if (input.kind === "routine") {
|
||||
const row = await db
|
||||
.update(routines)
|
||||
.set({ folderId: targetFolder?.id ?? null, updatedAt: new Date() })
|
||||
.where(and(eq(routines.companyId, companyId), eq(routines.id, input.itemId)))
|
||||
.returning({ id: routines.id, folderId: routines.folderId })
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Routine not found");
|
||||
return { kind: input.kind, itemId: row.id, folderId: row.folderId ?? null };
|
||||
}
|
||||
|
||||
const row = await db
|
||||
.update(companySkills)
|
||||
.set({ folderId: targetFolder?.id ?? null, updatedAt: new Date() })
|
||||
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, input.itemId)))
|
||||
.returning({ id: companySkills.id, folderId: companySkills.folderId })
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Skill not found");
|
||||
return { kind: input.kind, itemId: row.id, folderId: row.folderId ?? null };
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
create,
|
||||
update,
|
||||
moveFolder,
|
||||
deleteFolder,
|
||||
moveItem,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export { companySearchService } from "./company-search.js";
|
||||
export { feedbackService } from "./feedback.js";
|
||||
export { companySkillService } from "./company-skills.js";
|
||||
export { companySkillPolicyService, normalizeSkillPolicySourceType } from "./company-skill-policy.js";
|
||||
export { folderService } from "./folders.js";
|
||||
export { agentService, deduplicateAgentName } from "./agents.js";
|
||||
export {
|
||||
builtInAgentService,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
documentRevisions,
|
||||
documents,
|
||||
executionWorkspaces,
|
||||
folders,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
issueInboxArchives,
|
||||
@@ -958,6 +959,18 @@ export function routineService(
|
||||
if (parentIssue.companyId !== companyId) throw unprocessable("Parent issue must belong to same company");
|
||||
}
|
||||
|
||||
async function assertRoutineFolder(companyId: string, folderId: string | null | undefined) {
|
||||
if (!folderId) return;
|
||||
const folder = await db
|
||||
.select({ id: folders.id, companyId: folders.companyId, kind: folders.kind })
|
||||
.from(folders)
|
||||
.where(eq(folders.id, folderId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!folder) throw notFound("Folder not found");
|
||||
if (folder.companyId !== companyId) throw unprocessable("Folder must belong to same company");
|
||||
if (folder.kind !== "routine") throw unprocessable("Folder kind must match routine");
|
||||
}
|
||||
|
||||
async function listTriggersForRoutineIds(companyId: string, routineIds: string[]) {
|
||||
if (routineIds.length === 0) return new Map<string, RoutineTrigger[]>();
|
||||
const rows = await db
|
||||
@@ -2047,6 +2060,7 @@ export function routineService(
|
||||
|
||||
create: async (companyId: string, input: CreateRoutine, actor: Actor): Promise<Routine> => {
|
||||
await assertProject(companyId, input.projectId ?? null);
|
||||
await assertRoutineFolder(companyId, input.folderId ?? null);
|
||||
await assertAssignableAgent(db, companyId, input.assigneeAgentId ?? null, { kind: "routine" });
|
||||
if (input.goalId) await assertGoal(companyId, input.goalId);
|
||||
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
|
||||
@@ -2073,6 +2087,7 @@ export function routineService(
|
||||
.values({
|
||||
companyId,
|
||||
projectId: input.projectId ?? null,
|
||||
folderId: input.folderId ?? null,
|
||||
goalId: input.goalId ?? null,
|
||||
parentIssueId: input.parentIssueId ?? null,
|
||||
title: input.title,
|
||||
@@ -2111,6 +2126,7 @@ export function routineService(
|
||||
const existing = await getRoutineById(id);
|
||||
if (!existing) return null;
|
||||
const nextProjectId = patch.projectId === undefined ? existing.projectId : patch.projectId;
|
||||
const nextFolderId = patch.folderId === undefined ? existing.folderId : patch.folderId;
|
||||
const nextAssigneeAgentId = patch.assigneeAgentId === undefined ? existing.assigneeAgentId : patch.assigneeAgentId;
|
||||
const nextTitle = patch.title ?? existing.title;
|
||||
const nextDescription = patch.description === undefined ? existing.description : patch.description;
|
||||
@@ -2134,6 +2150,7 @@ export function routineService(
|
||||
patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables),
|
||||
);
|
||||
if (patch.projectId !== undefined) await assertProject(existing.companyId, nextProjectId);
|
||||
if (patch.folderId !== undefined) await assertRoutineFolder(existing.companyId, nextFolderId);
|
||||
if (patch.assigneeAgentId !== undefined || patch.status === "active") {
|
||||
await assertAssignableAgent(db, existing.companyId, nextAssigneeAgentId, { kind: "routine" });
|
||||
}
|
||||
@@ -2183,6 +2200,7 @@ export function routineService(
|
||||
const candidate: RoutineRow = {
|
||||
...locked,
|
||||
projectId: nextProjectId,
|
||||
folderId: nextFolderId,
|
||||
goalId: patch.goalId === undefined ? locked.goalId : patch.goalId,
|
||||
parentIssueId: patch.parentIssueId === undefined ? locked.parentIssueId : patch.parentIssueId,
|
||||
title: nextTitle,
|
||||
@@ -2199,8 +2217,20 @@ export function routineService(
|
||||
updatedByUserId: actor.userId ?? null,
|
||||
};
|
||||
|
||||
const folderChanged = patch.folderId !== undefined && locked.folderId !== candidate.folderId;
|
||||
if (locked.latestRevisionId && routineCurrentFieldsMatch(locked, candidate)) {
|
||||
return locked;
|
||||
if (!folderChanged) return locked;
|
||||
const [updated] = await txDb
|
||||
.update(routines)
|
||||
.set({
|
||||
folderId: candidate.folderId,
|
||||
updatedByAgentId: actor.agentId ?? null,
|
||||
updatedByUserId: actor.userId ?? null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(routines.id, id))
|
||||
.returning();
|
||||
return updated ?? locked;
|
||||
}
|
||||
|
||||
const nextSnapshot = await buildRoutineRevisionSnapshot(txDb, candidate);
|
||||
@@ -2233,6 +2263,7 @@ export function routineService(
|
||||
.update(routines)
|
||||
.set({
|
||||
projectId: candidate.projectId,
|
||||
folderId: candidate.folderId,
|
||||
goalId: candidate.goalId,
|
||||
parentIssueId: candidate.parentIssueId,
|
||||
title: candidate.title,
|
||||
|
||||
Reference in new issue
Block a user