fix: enforce annotation ownership

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-23 13:34:38 +00:00
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(() => {
+135 -17
View File
@@ -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" } };