-
Notifications
You must be signed in to change notification settings - Fork 16
feat: add CRUD operations for blog post plugin #114
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
1ad6001
feat: add CRUD operations for blog posts with tag management
olliethedev 30b4932
fix: guard tag deletion in updatePost to prevent silent data loss on …
cursoragent 4665fc0
fix: pass transaction to findOrCreateTags in updatePost to prevent or…
cursoragent 03cea0a
fix: update findOrCreateTags call to use adapter instead of transacti…
olliethedev e62125c
fix: fix server-generated timestamps in post data and ensure correct …
olliethedev e5d3f7f
docs: enhance blog plugin documentation to include mutation functions
olliethedev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,266 @@ | ||
| import type { DBAdapter as Adapter } from "@btst/db"; | ||
| import type { Post, Tag } from "../types"; | ||
| import { slugify } from "../utils"; | ||
|
|
||
| type TagInput = { name: string } | { id: string; name: string; slug: string }; | ||
|
|
||
| /** | ||
| * Find existing tags by slug or create missing ones, then return the resolved Tag records. | ||
| * Tags that already carry an `id` are returned as-is (after name normalisation). | ||
| */ | ||
| async function findOrCreateTags( | ||
| adapter: Adapter, | ||
| tagInputs: TagInput[], | ||
| ): Promise<Tag[]> { | ||
| if (tagInputs.length === 0) return []; | ||
|
|
||
| const normalizeTagName = (name: string): string => name.trim(); | ||
|
|
||
| const tagsWithIds: Tag[] = []; | ||
| const tagsToFindOrCreate: Array<{ name: string }> = []; | ||
|
|
||
| for (const tagInput of tagInputs) { | ||
| if ("id" in tagInput && tagInput.id) { | ||
| tagsWithIds.push({ | ||
| id: tagInput.id, | ||
| name: normalizeTagName(tagInput.name), | ||
| slug: tagInput.slug, | ||
| createdAt: new Date(), | ||
| updatedAt: new Date(), | ||
| } as Tag); | ||
| } else { | ||
| tagsToFindOrCreate.push({ name: normalizeTagName(tagInput.name) }); | ||
| } | ||
| } | ||
|
|
||
| if (tagsToFindOrCreate.length === 0) { | ||
| return tagsWithIds; | ||
| } | ||
|
|
||
| const allTags = await adapter.findMany<Tag>({ model: "tag" }); | ||
| const tagMapBySlug = new Map<string, Tag>(); | ||
| for (const tag of allTags) { | ||
| tagMapBySlug.set(tag.slug, tag); | ||
| } | ||
|
|
||
| const tagSlugs = tagsToFindOrCreate.map((tag) => slugify(tag.name)); | ||
| const foundTags: Tag[] = []; | ||
| for (const slug of tagSlugs) { | ||
| const tag = tagMapBySlug.get(slug); | ||
| if (tag) { | ||
| foundTags.push(tag); | ||
| } | ||
| } | ||
|
|
||
| const existingSlugs = new Set([ | ||
| ...tagsWithIds.map((tag) => tag.slug), | ||
| ...foundTags.map((tag) => tag.slug), | ||
| ]); | ||
| const tagsToCreate = tagsToFindOrCreate.filter( | ||
| (tag) => !existingSlugs.has(slugify(tag.name)), | ||
| ); | ||
|
|
||
| const createdTags: Tag[] = []; | ||
| for (const tag of tagsToCreate) { | ||
| const normalizedName = normalizeTagName(tag.name); | ||
| const newTag = await adapter.create<Tag>({ | ||
| model: "tag", | ||
| data: { | ||
| name: normalizedName, | ||
| slug: slugify(normalizedName), | ||
| createdAt: new Date(), | ||
| updatedAt: new Date(), | ||
| }, | ||
| }); | ||
| createdTags.push(newTag); | ||
| } | ||
|
|
||
| return [...tagsWithIds, ...foundTags, ...createdTags]; | ||
| } | ||
|
|
||
| /** | ||
| * Input for creating a new blog post. | ||
| * `slug` must already be slugified by the caller. | ||
| */ | ||
| export interface CreatePostInput { | ||
| title: string; | ||
| content: string; | ||
| excerpt: string; | ||
| /** Pre-slugified URL slug — use {@link slugify} before passing. */ | ||
| slug: string; | ||
| image?: string; | ||
| published?: boolean; | ||
| publishedAt?: Date; | ||
| createdAt?: Date; | ||
| updatedAt?: Date; | ||
| tags?: TagInput[]; | ||
| } | ||
|
|
||
| /** | ||
| * Input for updating an existing blog post. | ||
| * If `slug` is provided it must already be slugified by the caller. | ||
| */ | ||
| export interface UpdatePostInput { | ||
| title?: string; | ||
| content?: string; | ||
| excerpt?: string; | ||
| /** Pre-slugified URL slug — use {@link slugify} before passing. */ | ||
| slug?: string; | ||
| image?: string; | ||
| published?: boolean; | ||
| publishedAt?: Date; | ||
| createdAt?: Date; | ||
| updatedAt?: Date; | ||
| tags?: TagInput[]; | ||
| } | ||
|
|
||
| /** | ||
| * Create a new blog post with optional tag associations. | ||
| * Pure DB function — no hooks, no HTTP context. Safe for server-side and SSG use. | ||
| * | ||
| * @remarks **Security:** Authorization hooks (e.g. `onBeforeCreatePost`) are NOT | ||
| * called. The caller is responsible for any access-control checks before | ||
| * invoking this function. | ||
| * | ||
| * @param adapter - The database adapter | ||
| * @param input - Post data; `slug` must be pre-slugified | ||
| */ | ||
| export async function createPost( | ||
| adapter: Adapter, | ||
| input: CreatePostInput, | ||
| ): Promise<Post> { | ||
| const { tags: tagInputs, ...postData } = input; | ||
| const tagList = tagInputs ?? []; | ||
|
|
||
| const newPost = await adapter.create<Post>({ | ||
| model: "post", | ||
| data: { | ||
| ...postData, | ||
| published: postData.published ?? false, | ||
| tags: [] as Tag[], | ||
| createdAt: postData.createdAt ?? new Date(), | ||
| updatedAt: postData.updatedAt ?? new Date(), | ||
| }, | ||
| }); | ||
|
|
||
| if (tagList.length > 0) { | ||
| const resolvedTags = await findOrCreateTags(adapter, tagList); | ||
|
|
||
| await adapter.transaction(async (tx) => { | ||
| for (const tag of resolvedTags) { | ||
| await tx.create<{ postId: string; tagId: string }>({ | ||
| model: "postTag", | ||
| data: { | ||
| postId: newPost.id, | ||
| tagId: tag.id, | ||
| }, | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| newPost.tags = resolvedTags.map((tag) => ({ ...tag })); | ||
| } else { | ||
| newPost.tags = []; | ||
| } | ||
|
|
||
| return newPost; | ||
| } | ||
|
|
||
| /** | ||
| * Update an existing blog post and reconcile its tag associations. | ||
| * Returns `null` if no post with the given `id` exists. | ||
| * Pure DB function — no hooks, no HTTP context. Safe for server-side use. | ||
| * | ||
| * @remarks **Security:** Authorization hooks (e.g. `onBeforeUpdatePost`) are NOT | ||
| * called. The caller is responsible for any access-control checks before | ||
| * invoking this function. | ||
| * | ||
| * @param adapter - The database adapter | ||
| * @param id - The post ID to update | ||
| * @param input - Partial post data to apply; `slug` must be pre-slugified if provided | ||
| */ | ||
| export async function updatePost( | ||
| adapter: Adapter, | ||
| id: string, | ||
| input: UpdatePostInput, | ||
| ): Promise<Post | null> { | ||
| const { tags: tagInputs, ...postData } = input; | ||
| const tagList = tagInputs ?? []; | ||
|
|
||
| return adapter.transaction(async (tx) => { | ||
| const existingPostTags = await tx.findMany<{ | ||
| postId: string; | ||
| tagId: string; | ||
| }>({ | ||
| model: "postTag", | ||
| where: [{ field: "postId", value: id, operator: "eq" as const }], | ||
| }); | ||
|
|
||
| const updatedPost = await tx.update<Post>({ | ||
| model: "post", | ||
| where: [{ field: "id", value: id }], | ||
| update: { | ||
| ...postData, | ||
| updatedAt: new Date(), | ||
| }, | ||
| }); | ||
|
|
||
| if (!updatedPost) return null; | ||
|
|
||
| for (const postTag of existingPostTags) { | ||
| await tx.delete<{ postId: string; tagId: string }>({ | ||
| model: "postTag", | ||
| where: [ | ||
| { | ||
| field: "postId", | ||
| value: postTag.postId, | ||
| operator: "eq" as const, | ||
| }, | ||
| { | ||
| field: "tagId", | ||
| value: postTag.tagId, | ||
| operator: "eq" as const, | ||
| }, | ||
| ], | ||
| }); | ||
| } | ||
|
|
||
| if (tagList.length > 0) { | ||
| const resolvedTags = await findOrCreateTags(adapter, tagList); | ||
|
|
||
| for (const tag of resolvedTags) { | ||
| await tx.create<{ postId: string; tagId: string }>({ | ||
| model: "postTag", | ||
| data: { | ||
| postId: id, | ||
| tagId: tag.id, | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| updatedPost.tags = resolvedTags.map((tag) => ({ ...tag })); | ||
| } else { | ||
| updatedPost.tags = []; | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
olliethedev marked this conversation as resolved.
|
||
|
|
||
| return updatedPost; | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Delete a blog post by ID. | ||
| * Pure DB function — no hooks, no HTTP context. Safe for server-side use. | ||
| * | ||
| * @remarks **Security:** Authorization hooks (e.g. `onBeforeDeletePost`) are NOT | ||
| * called. The caller is responsible for any access-control checks before | ||
| * invoking this function. | ||
| * | ||
| * @param adapter - The database adapter | ||
| * @param id - The post ID to delete | ||
| */ | ||
| export async function deletePost(adapter: Adapter, id: string): Promise<void> { | ||
| await adapter.delete<Post>({ | ||
| model: "post", | ||
| where: [{ field: "id", value: id }], | ||
| }); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.