Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"ai": "6.0.206",
"autumn-js": "^1.2.5",
"drizzle-orm": "^0.45.2",
"effect": "4.0.0-beta.93",
"eve": "0.19.0",
"hono": "^4.12.30",
"jose": "^6.2.3",
Expand Down
105 changes: 84 additions & 21 deletions apps/api/src/routes/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import {
setContentGenerationJobStatus,
updateContentGenerationJob,
} from "@notra/content-generation/jobs";
import { postCollections, posts } from "@notra/db/schema";
import { postApprovalRequests, postCollections, posts } from "@notra/db/schema";
import { buildPostCollectionName } from "@notra/db/utils/post-collections";
import { and, count, eq, inArray, sql } from "drizzle-orm";
import { Effect } from "effect";
import { nanoid } from "nanoid";

import {
Expand Down Expand Up @@ -49,6 +50,7 @@ import { createOpenApiApp } from "../utils/openapi-app";
import { errorResponse, rateLimitResponse } from "../utils/openapi-responses";
import { getOrganizationResponse } from "../utils/organizations";
import { isConstraintViolation, isPgUniqueViolation } from "../utils/pg-errors";
import { assertApiPublishAllowed } from "../utils/publishing";
import { enforceRatelimit, RATE_LIMITS, ratelimit } from "../utils/ratelimit";
import { getRedis } from "../utils/redis";

Expand Down Expand Up @@ -92,7 +94,7 @@ function serializePost(post: {
recommendations: string | null;
slug: string | null;
sourceMetadata: unknown;
status: "draft" | "published";
status: "draft" | "in_review" | "approved" | "published";
title: string;
updatedAt: Date;
}) {
Expand Down Expand Up @@ -490,17 +492,46 @@ postsRoutes.openapi(patchPostRoute, async (c) => {
title: true,
slug: true,
contentType: true,
status: true,
createdBy: true,
},
});

if (!existingPost) {
return c.json({ error: "Post not found" }, 404);
}

if (body.status === "published" && existingPost.status !== "published") {
const publishCheck = await Effect.runPromise(
assertApiPublishAllowed({
db,
organizationId: orgId,
post: existingPost,
}).pipe(
Effect.match({
onFailure: (error) => error,
onSuccess: () => null,
})
)
);

if (publishCheck?._tag === "PublishRequirementError") {
return c.json({ error: publishCheck.message }, 403);
}

if (publishCheck) {
throw new Error(publishCheck.message, { cause: publishCheck.cause });
}
}

const updateData: Partial<typeof posts.$inferInsert> = {
updatedAt: new Date(),
};

if (body.status === "published" && existingPost.status !== "published") {
updateData.publishedAt = new Date();
}

if (body.title !== undefined) {
updateData.title = body.title;
}
Expand Down Expand Up @@ -549,30 +580,56 @@ postsRoutes.openapi(patchPostRoute, async (c) => {
recommendations: string | null;
contentType: string;
sourceMetadata: unknown;
status: "draft" | "published";
status: "draft" | "in_review" | "approved" | "published";
createdAt: Date;
updatedAt: Date;
}> = [];

const isStatusChange =
body.status !== undefined && body.status !== existingPost.status;

try {
updatedRows = await db
.update(posts)
.set(updateData)
.where(and(eq(posts.id, postId), eq(posts.organizationId, orgId)))
.returning({
id: posts.id,
title: posts.title,
slug: posts.slug,
content: posts.content,
htmlUrl: posts.htmlUrl,
markdown: posts.markdown,
recommendations: posts.recommendations,
contentType: posts.contentType,
sourceMetadata: posts.sourceMetadata,
status: posts.status,
createdAt: posts.createdAt,
updatedAt: posts.updatedAt,
});
updatedRows = await db.transaction(async (tx) => {
const rows = await tx
.update(posts)
.set(updateData)
.where(
and(
eq(posts.id, postId),
eq(posts.organizationId, orgId),
...(isStatusChange ? [eq(posts.status, existingPost.status)] : [])
)
)
.returning({
id: posts.id,
title: posts.title,
slug: posts.slug,
content: posts.content,
htmlUrl: posts.htmlUrl,
markdown: posts.markdown,
recommendations: posts.recommendations,
contentType: posts.contentType,
sourceMetadata: posts.sourceMetadata,
status: posts.status,
createdAt: posts.createdAt,
updatedAt: posts.updatedAt,
});

if (rows.length > 0 && isStatusChange) {
await tx
.update(postApprovalRequests)
.set({ status: "canceled", resolvedAt: new Date() })
.where(
and(
eq(postApprovalRequests.postId, postId),
eq(postApprovalRequests.organizationId, orgId),
eq(postApprovalRequests.status, "pending")
)
);
}

return rows;
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.
} catch (error) {
if (
isPgUniqueViolation(error) &&
Expand All @@ -587,6 +644,12 @@ postsRoutes.openapi(patchPostRoute, async (c) => {
const [updatedPost] = updatedRows;

if (!updatedPost) {
if (isStatusChange) {
return c.json(
{ error: "The post status just changed. Refresh and try again." },
409
);
}
return c.json({ error: "Post not found" }, 404);
}

Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/schemas/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const HTTP_PROTOCOL_REGEX = /^https?:\/\//i;
export const getPostsParamsSchema = z.object({});

const postStatusSchema = z.enum(postStatusEnum.enumValues);
const editablePostStatusSchema = z.enum(["draft", "published"]);
const postContentTypeSchema = z.enum([
"changelog",
"linkedin_post",
Expand Down Expand Up @@ -401,7 +402,7 @@ export const patchPostRequestSchema = z
.openapi({
example: "# Ship notes\n\nWe shipped a faster editor.",
}),
status: postStatusSchema.optional().openapi({
status: editablePostStatusSchema.optional().openapi({
example: "published",
}),
})
Expand Down
129 changes: 129 additions & 0 deletions apps/api/src/utils/publishing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { createDb } from "@notra/db/drizzle";
import {
accessGroupMembers,
approvalWorkflows,
members,
} from "@notra/db/schema";
import { and, asc, eq } from "drizzle-orm";
import { Data, Effect } from "effect";

type Database = ReturnType<typeof createDb>;

class PublishRequirementError extends Data.TaggedError(
"PublishRequirementError"
)<{
readonly message: string;
}> {}

class PublishCheckError extends Data.TaggedError("PublishCheckError")<{
readonly message: string;
readonly cause: unknown;
}> {}

const tryDb = <T>(run: () => Promise<T>) =>
Effect.tryPromise({
try: run,
catch: (cause) =>
new PublishCheckError({
message: "Failed to check publishing requirements",
cause,
}),
});

const getAuthorAccessGroupIds = Effect.fn("getAuthorAccessGroupIds")(
function* ({
db,
organizationId,
authorUserId,
}: {
db: Database;
organizationId: string;
authorUserId: string | null;
}) {
if (!authorUserId) {
return [] as string[];
}

const membership = yield* tryDb(() =>
db.query.members.findFirst({
where: and(
eq(members.userId, authorUserId),
eq(members.organizationId, organizationId)
),
columns: {
id: true,
},
})
);

if (!membership) {
return [] as string[];
}

const memberships = yield* tryDb(() =>
db.query.accessGroupMembers.findMany({
where: eq(accessGroupMembers.memberId, membership.id),
columns: {
accessGroupId: true,
},
})
);

return memberships.map((groupMembership) => groupMembership.accessGroupId);
}
);

export const assertApiPublishAllowed = Effect.fn("assertApiPublishAllowed")(
function* ({
db,
organizationId,
post,
}: {
db: Database;
organizationId: string;
post: {
status: "draft" | "in_review" | "approved" | "published";
createdBy: string | null;
};
}) {
if (post.status === "approved" || post.status === "published") {
return;
}

const authorAccessGroupIds = yield* getAuthorAccessGroupIds({
db,
organizationId,
authorUserId: post.createdBy,
});

const workflows = yield* tryDb(() =>
db.query.approvalWorkflows.findMany({
where: eq(approvalWorkflows.organizationId, organizationId),
with: {
steps: {
columns: {
id: true,
},
},
},
orderBy: [asc(approvalWorkflows.createdAt)],
})
);

const withSteps = workflows.filter((workflow) => workflow.steps.length > 0);
const applicable =
withSteps.find(
(workflow) =>
workflow.appliesToAccessGroupId &&
authorAccessGroupIds.includes(workflow.appliesToAccessGroupId)
) ?? withSteps.find((workflow) => workflow.isDefault);

if (applicable) {
return yield* Effect.fail(
new PublishRequirementError({
message: `This post must be approved through the "${applicable.name}" workflow before it can be published`,
})
);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { RenameCollectionDialog } from "@/components/content/group/rename-collec
import { EmptyState } from "@/components/empty-state";
import { PageContainer } from "@/components/layout/container";
import { useCollection } from "@/lib/hooks/use-collections";
import { useMemberPermissions } from "@/lib/hooks/use-member-permissions";
import type { CollectionDetailPageClientProps } from "@/types/content/collection";
import { formatLongDate, getMarkdownPreview } from "@/utils/content-preview";
import { resolveImagePreviewSrc } from "@/utils/markdown-image";
Expand All @@ -26,6 +27,8 @@ export default function PageClient({
organizationId,
collectionId
);
const { hasScope } = useMemberPermissions(organizationId);
const canEditContent = hasScope("posts:edit");
const [showRenameDialog, setShowRenameDialog] = useState(false);

if (isPending) {
Expand Down Expand Up @@ -87,15 +90,17 @@ export default function PageClient({
<h1 className="font-bold text-2xl tracking-tight">
{collection.name}
</h1>
<Button
className="size-7 shrink-0 text-muted-foreground"
onClick={() => setShowRenameDialog(true)}
size="icon-sm"
variant="ghost"
>
<span className="sr-only">Rename collection</span>
<HugeiconsIcon className="size-4" icon={PencilEdit02Icon} />
</Button>
{canEditContent && (
<Button
className="size-7 shrink-0 text-muted-foreground"
onClick={() => setShowRenameDialog(true)}
size="icon-sm"
variant="ghost"
>
<span className="sr-only">Rename collection</span>
<HugeiconsIcon className="size-4" icon={PencilEdit02Icon} />
</Button>
)}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground text-sm">
<span>{postCountLabel}</span>
Expand Down
Loading
Loading