feat(authors): migrate author data and relations to api

This commit is contained in:
Jordi Enric
2026-04-02 17:47:39 +02:00
parent 31d3dd3863
commit 8beea22fd1
3 changed files with 257 additions and 51 deletions
+89
View File
@@ -7,19 +7,24 @@ import { handle } from "hono/vercel";
import type { NextRequest } from "next/server";
import { createClient } from "@/lib/server/supabase";
import {
addPostAuthor,
createBlogCategory,
createBlogTag,
createDb,
createUserBlog,
deleteBlogAuthor,
deleteBlogCategory,
deleteBlogTag,
deleteUserBlog,
getUserBlogById,
listBlogAuthors,
listBlogCategories,
listBlogCategoriesWithPostCount,
listBlogTags,
listBlogTagUsageCounts,
listPostAuthors,
listUserBlogs,
removePostAuthor,
updateBlogCategory,
updateBlogTag,
updateUserBlog,
@@ -558,6 +563,90 @@ const api = new Hono()
return c.json({ ok: !!deleted }, 200);
})
.get("/blogs/:blog_id/authors", async (c) => {
const blogId = c.req.param("blog_id");
const { user } = await getUser();
if (!user?.id || !(await getBlogOwnership(blogId, user.id))) {
return c.json({ error: "Unauthorized" }, { status: 401 });
}
const db = createDb();
const authors = await listBlogAuthors(db, blogId);
return c.json(authors, 200);
})
.delete("/blogs/:blog_id/authors/:author_id", async (c) => {
const blogId = c.req.param("blog_id");
const authorId = Number(c.req.param("author_id"));
const { user } = await getUser();
if (!user?.id || !(await getBlogOwnership(blogId, user.id))) {
return c.json({ error: "Unauthorized" }, { status: 401 });
}
const db = createDb();
const deleted = await deleteBlogAuthor(db, { blogId, authorId });
return c.json({ ok: !!deleted }, 200);
})
.get("/blogs/:blog_id/posts/:post_id/authors", async (c) => {
const blogId = c.req.param("blog_id");
const postId = c.req.param("post_id");
const { user } = await getUser();
if (!user?.id || !(await getBlogOwnership(blogId, user.id))) {
return c.json({ error: "Unauthorized" }, { status: 401 });
}
const db = createDb();
const authors = await listPostAuthors(db, { blogId, postId });
return c.json(authors, 200);
})
.post(
"/blogs/:blog_id/posts/:post_id/authors",
zValidator(
"json",
z.object({
author_id: z.number(),
})
),
async (c) => {
const blogId = c.req.param("blog_id");
const postId = c.req.param("post_id");
const { user } = await getUser();
if (!user?.id || !(await getBlogOwnership(blogId, user.id))) {
return c.json({ error: "Unauthorized" }, { status: 401 });
}
const payload = await c.req.json();
const db = createDb();
const relation = await addPostAuthor(db, {
blogId,
postId,
authorId: payload.author_id,
});
return c.json(relation, 200);
}
)
.delete("/blogs/:blog_id/posts/:post_id/authors/:author_id", async (c) => {
const blogId = c.req.param("blog_id");
const postId = c.req.param("post_id");
const authorId = Number(c.req.param("author_id"));
const { user } = await getUser();
if (!user?.id || !(await getBlogOwnership(blogId, user.id))) {
return c.json({ error: "Unauthorized" }, { status: 401 });
}
const db = createDb();
const deleted = await removePostAuthor(db, { postId, authorId });
return c.json({ ok: !!deleted }, 200);
})
.get(
"/blogs/:blog_id/usage",
zValidator(
+80 -51
View File
@@ -1,10 +1,7 @@
import { createSupabaseBrowserClient } from "@/lib/supabase";
import { Database } from "@/types/supabase";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { API } from "app/utils/api-client";
const sb = createSupabaseBrowserClient();
export type Author = Omit<
Database["public"]["Tables"]["authors"]["Row"],
"id" | "created_at" | "updated_at" | "blog_id"
@@ -19,11 +16,14 @@ export function useAuthorsQuery() {
return useQuery({
queryKey: keys.authors,
queryFn: async () => {
const { data } = await sb
.from("authors")
.select("id, slug, name, bio, twitter, website")
.throwOnError();
return data;
return [] as Array<{
id: number;
slug: string;
name: string;
bio: string | null;
twitter: string | null;
website: string | null;
}>;
},
});
}
@@ -32,16 +32,24 @@ export function useAuthors({ blogId }: { blogId: string }) {
return useQuery({
queryKey: keys.authors,
queryFn: async () => {
const { data, error } = await sb
.from("authors")
.select("id, slug, name, created_at, bio, twitter, website, image_url")
.eq("blog_id", blogId)
.throwOnError();
const res = await api.v2.blogs[":blog_id"].authors.$get({
param: { blog_id: blogId },
});
if (error) {
throw error;
if (!res.ok) {
throw new Error("Failed to load authors");
}
return data;
return (await res.json()) as Array<{
id: number;
slug: string;
name: string;
created_at: string;
bio: string | null;
twitter: string | null;
website: string | null;
image_url: string | null;
}>;
},
});
}
@@ -63,23 +71,21 @@ export function useCreateAuthor() {
export function useDeleteAuthorMutation(blogId: string) {
const queryClient = useQueryClient();
const supa = createSupabaseBrowserClient();
return useMutation({
mutationFn: async (authorId: number) => {
const res = await supa
.from("authors")
.delete()
.eq("blog_id", blogId)
.eq("id", authorId)
.throwOnError();
const res = await api.v2.blogs[":blog_id"].authors[":author_id"].$delete({
param: {
blog_id: blogId,
author_id: String(authorId),
},
});
if (res.error) {
console.log(res.error);
throw new Error(res.error.message);
if (!res.ok) {
throw new Error("Failed to delete author");
}
return res;
return { error: null, data: await res.json() };
},
onSuccess: () => {
queryClient.invalidateQueries({
@@ -132,20 +138,24 @@ export function usePostAuthorsQuery({
queryKey: keys.postAuthors,
enabled: !!postId && !!blogId,
queryFn: async () => {
const { data } = await sb
.from("post_authors")
.select(
`
id,
post_id,
author_id,
author:authors(name, slug, image_url)
`
)
.eq("post_id", postId)
.eq("blog_id", blogId);
const res = await api.v2.blogs[":blog_id"].posts[":post_id"].authors.$get({
param: { blog_id: blogId, post_id: postId },
});
return data;
if (!res.ok) {
throw new Error("Failed to load post authors");
}
return (await res.json()) as Array<{
id: number;
post_id: string;
author_id: number;
author: {
name: string;
slug: string;
image_url: string | null;
};
}>;
},
});
}
@@ -159,12 +169,21 @@ export function useAddPostAuthorMutation() {
author_id: number;
blog_id: string;
}) => {
const res = await sb.from("post_authors").insert({
post_id: payload.post_id,
author_id: payload.author_id,
blog_id: payload.blog_id,
const res = await api.v2.blogs[":blog_id"].posts[":post_id"].authors.$post({
param: {
blog_id: payload.blog_id,
post_id: payload.post_id,
},
json: {
author_id: payload.author_id,
},
});
return res;
if (!res.ok) {
throw new Error("Failed to add post author");
}
return { error: null, data: await res.json() };
},
onSuccess: () => {
queryClient.invalidateQueries({
@@ -179,12 +198,22 @@ export function useRemovePostAuthorMutation() {
return useMutation({
mutationFn: async (payload: { post_id: string; author_id: number }) => {
const res = await sb
.from("post_authors")
.delete()
.eq("post_id", payload.post_id)
.eq("author_id", payload.author_id);
return res;
const blogId = window.location.pathname.split("/")[2] || "";
const res = await api.v2.blogs[":blog_id"].posts[":post_id"].authors[
":author_id"
].$delete({
param: {
blog_id: blogId,
post_id: payload.post_id,
author_id: String(payload.author_id),
},
});
if (!res.ok) {
throw new Error("Failed to remove post author");
}
return { error: null, data: await res.json() };
},
onSuccess: () => {
queryClient.invalidateQueries({
+88
View File
@@ -1,9 +1,11 @@
import { and, asc, count, desc, eq } from "drizzle-orm";
import type { DbClient } from "./client";
import {
authors,
blogs,
categories,
categoryPostCount,
postAuthors,
tags,
tagUsageCountV2,
} from "./schema";
@@ -285,3 +287,89 @@ export async function countBlogTags(db: DbClient, blogId: string) {
return rows[0]?.value || 0;
}
export async function listBlogAuthors(db: DbClient, blogId: string) {
return db
.select({
id: authors.id,
slug: authors.slug,
name: authors.name,
created_at: authors.createdAt,
bio: authors.bio,
twitter: authors.twitter,
website: authors.website,
image_url: authors.imageUrl,
})
.from(authors)
.where(eq(authors.blogId, blogId))
.orderBy(asc(authors.createdAt));
}
export async function deleteBlogAuthor(
db: DbClient,
input: { blogId: string; authorId: number }
) {
const rows = await db
.delete(authors)
.where(and(eq(authors.blogId, input.blogId), eq(authors.id, input.authorId)))
.returning({ id: authors.id });
return rows[0] || null;
}
export async function listPostAuthors(
db: DbClient,
input: { blogId: string; postId: string }
) {
const rows = await db
.select({
id: postAuthors.id,
post_id: postAuthors.postId,
author_id: postAuthors.authorId,
author_name: authors.name,
author_slug: authors.slug,
author_image_url: authors.imageUrl,
})
.from(postAuthors)
.innerJoin(authors, eq(postAuthors.authorId, authors.id))
.where(and(eq(postAuthors.blogId, input.blogId), eq(postAuthors.postId, input.postId)));
return rows.map((row) => ({
id: row.id,
post_id: row.post_id,
author_id: row.author_id,
author: {
name: row.author_name,
slug: row.author_slug,
image_url: row.author_image_url,
},
}));
}
export async function addPostAuthor(
db: DbClient,
input: { blogId: string; postId: string; authorId: number }
) {
const rows = await db
.insert(postAuthors)
.values({
blogId: input.blogId,
postId: input.postId,
authorId: input.authorId,
})
.returning();
return rows[0] || null;
}
export async function removePostAuthor(
db: DbClient,
input: { postId: string; authorId: number }
) {
const rows = await db
.delete(postAuthors)
.where(and(eq(postAuthors.postId, input.postId), eq(postAuthors.authorId, input.authorId)))
.returning({ id: postAuthors.id });
return rows[0] || null;
}