mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
fix: enforce annotation ownership
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6db7676ca1
commit
b4a5672d0f
5 files changed
+169
-21
No files matched your search
@@ -62,6 +62,22 @@ DO $$ BEGIN
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'document_annotation_threads_owner_check'
|
||||
) THEN
|
||||
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_owner_check" CHECK ("issue_id" IS NOT NULL OR "routine_id" IS NOT NULL);
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'document_annotation_comments_owner_check'
|
||||
) THEN
|
||||
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_owner_check" CHECK ("issue_id" IS NOT NULL OR "routine_id" IS NOT NULL);
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_routine_status_idx" ON "document_annotation_threads" USING btree ("company_id","routine_id","status");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "document_annotation_comments_company_routine_created_at_idx" ON "document_annotation_comments" USING btree ("company_id","routine_id","created_at");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { IssueCommentAuthorType } from "@paperclipai/shared";
|
||||
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { check, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { agents } from "./agents.js";
|
||||
import { companies } from "./companies.js";
|
||||
import { documentAnnotationThreads } from "./document_annotation_threads.js";
|
||||
@@ -50,5 +51,9 @@ export const documentAnnotationComments = pgTable(
|
||||
),
|
||||
issueCommentIdx: index("document_annotation_comments_issue_comment_idx").on(table.issueCommentId),
|
||||
bodySearchIdx: index("document_annotation_comments_body_search_idx").using("gin", table.body.op("gin_trgm_ops")),
|
||||
ownerCheck: check(
|
||||
"document_annotation_comments_owner_check",
|
||||
sql`${table.issueId} IS NOT NULL OR ${table.routineId} IS NOT NULL`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -4,7 +4,8 @@ import type {
|
||||
DocumentAnnotationAnchorState,
|
||||
DocumentAnnotationThreadStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import { index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { check, index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { agents } from "./agents.js";
|
||||
import { companies } from "./companies.js";
|
||||
import { documentRevisions } from "./document_revisions.js";
|
||||
@@ -73,5 +74,9 @@ export const documentAnnotationThreads = pgTable(
|
||||
table.companyId,
|
||||
table.anchorState,
|
||||
),
|
||||
ownerCheck: check(
|
||||
"document_annotation_threads_owner_check",
|
||||
sql`${table.issueId} IS NOT NULL OR ${table.routineId} IS NOT NULL`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -78,7 +78,11 @@ const signingModeDescriptions: Record<string, string> = {
|
||||
};
|
||||
const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]);
|
||||
|
||||
export function OverviewSection() {
|
||||
export function OverviewSection({
|
||||
defaultDescriptionAnnotationsOpen = false,
|
||||
}: {
|
||||
defaultDescriptionAnnotationsOpen?: boolean;
|
||||
} = {}) {
|
||||
const ctx = useRoutineDetail();
|
||||
const {
|
||||
routine,
|
||||
@@ -103,7 +107,7 @@ export function OverviewSection() {
|
||||
isSectionDirty,
|
||||
navigateToSection,
|
||||
} = ctx;
|
||||
const [descriptionAnnotationsOpen, setDescriptionAnnotationsOpen] = useState(false);
|
||||
const [descriptionAnnotationsOpen, setDescriptionAnnotationsOpen] = useState(defaultDescriptionAnnotationsOpen);
|
||||
|
||||
const activeTriggers = routine.triggers.length;
|
||||
const nextFire = useMemo(() => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import type {
|
||||
DocumentAnnotationThreadWithComments,
|
||||
RoutineDescriptionDocument,
|
||||
RoutineDetail as RoutineDetailType,
|
||||
RoutineTrigger,
|
||||
RoutineVariable,
|
||||
@@ -29,6 +32,7 @@ import {
|
||||
RunsSection,
|
||||
ActivitySection,
|
||||
} from "@/components/routine-sections/operate-sections";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { storybookAgents, storybookProjects } from "../fixtures/paperclipData";
|
||||
|
||||
const COMPANY_ID = "company-storybook";
|
||||
@@ -103,6 +107,82 @@ const routine: RoutineDetailType = {
|
||||
activeIssue: null,
|
||||
};
|
||||
|
||||
const routineDescriptionDocument: RoutineDescriptionDocument = {
|
||||
id: "routine-description-doc",
|
||||
companyId: COMPANY_ID,
|
||||
routineId: ROUTINE_ID,
|
||||
key: "description",
|
||||
title: "Description",
|
||||
format: "markdown",
|
||||
body: routine.description ?? "",
|
||||
latestRevisionId: "routine-description-rev-17",
|
||||
latestRevisionNumber: 17,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-board",
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: "user-board",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const routineAnnotationThreads: DocumentAnnotationThreadWithComments[] = [
|
||||
{
|
||||
id: "routine-thread-1",
|
||||
companyId: COMPANY_ID,
|
||||
issueId: null,
|
||||
routineId: ROUTINE_ID,
|
||||
documentId: routineDescriptionDocument.id,
|
||||
documentKey: "description",
|
||||
status: "open",
|
||||
anchorState: "active",
|
||||
anchorConfidence: "exact",
|
||||
originalRevisionId: routineDescriptionDocument.latestRevisionId,
|
||||
originalRevisionNumber: routineDescriptionDocument.latestRevisionNumber,
|
||||
currentRevisionId: routineDescriptionDocument.latestRevisionId,
|
||||
currentRevisionNumber: routineDescriptionDocument.latestRevisionNumber,
|
||||
selectedText: "Keep it to five bullets",
|
||||
prefixText: "by {{deadline}}.\n\n",
|
||||
suffixText: ".",
|
||||
normalizedStart: 84,
|
||||
normalizedEnd: 108,
|
||||
markdownStart: 84,
|
||||
markdownEnd: 108,
|
||||
anchorSelector: {
|
||||
quote: {
|
||||
exact: "Keep it to five bullets",
|
||||
prefix: "by {{deadline}}.\n\n",
|
||||
suffix: ".",
|
||||
},
|
||||
position: { normalizedStart: 84, normalizedEnd: 108, markdownStart: 84, markdownEnd: 108 },
|
||||
},
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-board",
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
resolvedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
comments: [
|
||||
{
|
||||
id: "routine-comment-1",
|
||||
companyId: COMPANY_ID,
|
||||
threadId: "routine-thread-1",
|
||||
issueId: null,
|
||||
routineId: ROUTINE_ID,
|
||||
documentId: routineDescriptionDocument.id,
|
||||
body: "The digest constraint is visible here; the panel stays aligned with the routine overview editor.",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-board",
|
||||
createdByRunId: null,
|
||||
issueCommentId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const routineRuns = [
|
||||
{ id: "run-1", source: "manual", status: "succeeded", triggeredAt: new Date("2026-06-09T11:48:00Z"), failureReason: null, triggerPayload: { customer_name: "Acme", deadline: "Fri" }, trigger: { label: "manual", kind: "manual" }, linkedIssue: { id: "issue-1", identifier: "PAP-99221", title: "Weekly digest for Acme" } },
|
||||
{ id: "run-2", source: "schedule", status: "failed", triggeredAt: new Date("2026-06-08T14:00:00Z"), failureReason: "Cron timed out after 600s", triggerPayload: { customer_name: "Acme" }, trigger: { label: "schedule", kind: "schedule" }, linkedIssue: { id: "issue-2", identifier: "PAP-99220", title: "Weekly digest for Acme" } },
|
||||
@@ -156,17 +236,21 @@ function stubMutation(overrides?: Record<string, unknown>) {
|
||||
} as never;
|
||||
}
|
||||
|
||||
function makeContext(dirty: boolean, navigate: (s: RoutineSectionKey) => void): RoutineDetailContextValue {
|
||||
function makeContext(
|
||||
dirty: boolean,
|
||||
navigate: (s: RoutineSectionKey) => void,
|
||||
routineDetail: RoutineDetailType = routine,
|
||||
): RoutineDetailContextValue {
|
||||
const defaults: RoutineEditDraft = {
|
||||
title: routine.title,
|
||||
description: routine.description ?? "",
|
||||
projectId: routine.projectId ?? "",
|
||||
assigneeAgentId: routine.assigneeAgentId ?? "",
|
||||
priority: routine.priority,
|
||||
concurrencyPolicy: routine.concurrencyPolicy,
|
||||
catchUpPolicy: routine.catchUpPolicy,
|
||||
variables: routine.variables,
|
||||
env: routine.env ?? null,
|
||||
title: routineDetail.title,
|
||||
description: routineDetail.description ?? "",
|
||||
projectId: routineDetail.projectId ?? "",
|
||||
assigneeAgentId: routineDetail.assigneeAgentId ?? "",
|
||||
priority: routineDetail.priority,
|
||||
concurrencyPolicy: routineDetail.concurrencyPolicy,
|
||||
catchUpPolicy: routineDetail.catchUpPolicy,
|
||||
variables: routineDetail.variables,
|
||||
env: routineDetail.env ?? null,
|
||||
};
|
||||
const editDraft: RoutineEditDraft = dirty
|
||||
? { ...defaults, description: `${defaults.description}\n\nAlways CC the account owner.` }
|
||||
@@ -174,7 +258,7 @@ function makeContext(dirty: boolean, navigate: (s: RoutineSectionKey) => void):
|
||||
const dirtyFields = dirty ? [{ key: "description", label: "the description" }] : [];
|
||||
|
||||
return {
|
||||
routine,
|
||||
routine: routineDetail,
|
||||
routineId: ROUTINE_ID,
|
||||
companyId: COMPANY_ID,
|
||||
editDraft,
|
||||
@@ -242,10 +326,16 @@ const SECTION_TITLES: Record<RoutineSectionKey, string> = {
|
||||
history: "History",
|
||||
};
|
||||
|
||||
function SectionBody({ section }: { section: RoutineSectionKey }) {
|
||||
function SectionBody({
|
||||
section,
|
||||
descriptionAnnotationsInitiallyOpen = false,
|
||||
}: {
|
||||
section: RoutineSectionKey;
|
||||
descriptionAnnotationsInitiallyOpen?: boolean;
|
||||
}) {
|
||||
switch (section) {
|
||||
case "overview":
|
||||
return <OverviewSection />;
|
||||
return <OverviewSection defaultDescriptionAnnotationsOpen={descriptionAnnotationsInitiallyOpen} />;
|
||||
case "triggers":
|
||||
return <TriggersSection />;
|
||||
case "variables":
|
||||
@@ -263,9 +353,31 @@ function SectionBody({ section }: { section: RoutineSectionKey }) {
|
||||
}
|
||||
}
|
||||
|
||||
function RoutineCShell({ initialSection = "overview", dirty = false }: { initialSection?: RoutineSectionKey; dirty?: boolean }) {
|
||||
function RoutineCShell({
|
||||
initialSection = "overview",
|
||||
dirty = false,
|
||||
withDescriptionAnnotations = false,
|
||||
}: {
|
||||
initialSection?: RoutineSectionKey;
|
||||
dirty?: boolean;
|
||||
withDescriptionAnnotations?: boolean;
|
||||
}) {
|
||||
const [section, setSection] = useState<RoutineSectionKey>(initialSection);
|
||||
const ctx = useMemo(() => makeContext(dirty, setSection), [dirty]);
|
||||
const queryClient = useQueryClient();
|
||||
const routineDetail = useMemo(
|
||||
() => withDescriptionAnnotations
|
||||
? { ...routine, descriptionDocument: routineDescriptionDocument }
|
||||
: routine,
|
||||
[withDescriptionAnnotations],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!withDescriptionAnnotations) return;
|
||||
queryClient.setQueryData(
|
||||
queryKeys.routines.documentAnnotations(ROUTINE_ID, "description", "all"),
|
||||
routineAnnotationThreads,
|
||||
);
|
||||
}, [queryClient, withDescriptionAnnotations]);
|
||||
const ctx = useMemo(() => makeContext(dirty, setSection, routineDetail), [dirty, routineDetail]);
|
||||
const isEditable = EDITABLE_SECTIONS.includes(section);
|
||||
|
||||
return (
|
||||
@@ -300,7 +412,10 @@ function RoutineCShell({ initialSection = "overview", dirty = false }: { initial
|
||||
<main className="min-w-0 flex-1 px-4 pb-6 pt-10 md:px-8">
|
||||
<section className={isEditable ? "mx-auto w-full max-w-3xl" : "w-full"}>
|
||||
<h2 className="mb-4 text-lg font-semibold">{SECTION_TITLES[section]}</h2>
|
||||
<SectionBody section={section} />
|
||||
<SectionBody
|
||||
section={section}
|
||||
descriptionAnnotationsInitiallyOpen={withDescriptionAnnotations}
|
||||
/>
|
||||
{isEditable ? (
|
||||
<RoutineSaveBar
|
||||
dirtyFields={ctx.sectionDirtyFields(section)}
|
||||
@@ -333,6 +448,9 @@ export default meta;
|
||||
type Story = StoryObj<typeof RoutineCShell>;
|
||||
|
||||
export const Overview: Story = { args: { initialSection: "overview", dirty: true } };
|
||||
export const OverviewDescriptionAnnotations: Story = {
|
||||
args: { initialSection: "overview", withDescriptionAnnotations: true },
|
||||
};
|
||||
export const Triggers: Story = { args: { initialSection: "triggers" } };
|
||||
export const Variables: Story = { args: { initialSection: "variables" } };
|
||||
export const Secrets: Story = { args: { initialSection: "secrets" } };
|
||||
|
||||
Reference in new issue
Block a user