import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure, } from "@/server/api/trpc"; import { Article, articles } from "@/server/db/schema"; import { articleFilterSchema, articleSchema, createArticleSchema, } from "@/lib/validation/zod/article"; import { and, asc, count, desc, eq, gte, ilike, like, lte, sql, } from "drizzle-orm"; import { hasPermission, Role } from "@/lib/validation/permissions"; import { generateSlug } from "@/lib/utils"; import SuperJSON from "superjson"; type ArticleCursor = Pick; const getArticleSorting = (sort: string, cursor?: ArticleCursor) => { // Default to newest const baseCase = { orderBy: [desc(articles.createdAt), asc(articles.slug)], cursor: cursor ? lte(articles.createdAt, cursor.createdAt) : undefined, }; switch (sort) { case "newest": return baseCase; case "oldest": return { orderBy: [asc(articles.createdAt), asc(articles.slug)], cursor: cursor ? gte(articles.createdAt, cursor.createdAt) : undefined, }; case "abc": return { orderBy: [asc(articles.title), asc(articles.slug)], cursor: cursor ? gte(articles.title, cursor.title) : undefined, }; case "cba": return { orderBy: [desc(articles.title), asc(articles.slug)], cursor: cursor ? lte(articles.title, cursor.title) : undefined, }; default: return baseCase; } }; export const articleRouter = createTRPCRouter({ // queries search: publicProcedure .input(z.object({ query: z.string() })) .query(async ({ ctx, input }) => { return (await ctx.db.query.articles.findMany({ where: like(articles.title, "%" + input.query + "%"), with: { category: true }, })) as Article[]; }), get: publicProcedure .input(z.object({ slug: z.string(), with: z.any().optional() })) .query(async ({ ctx, input }) => { const user = ctx?.session?.user; const isEditor = user ? hasPermission(user.role, Role.EDITOR) : false; const publishedArg = !isEditor ? eq(articles.published, true) : undefined; return (await ctx.db.query.articles.findFirst({ where: and(eq(articles.slug, input.slug), publishedArg), with: input?.with, })) as Article; }), getByCursor: publicProcedure .input( z.object({ limit: z.number().optional(), cursor: z.string().optional(), filter: articleFilterSchema.optional(), }), ) .query(async ({ ctx, input }) => { const { cursor } = input!; const limit = input?.limit ?? 50; // Decode cursor if using it let cursorObj: ArticleCursor | undefined; if (cursor) { try { cursorObj = SuperJSON.parse(Buffer.from(cursor, "base64").toString()); } catch (e) { // Handle invalid cursor cursorObj = undefined; } } const queryFilterArg = input?.filter?.query?.length ? ilike(articles.title, "%" + input.filter.query + "%") : undefined; const categoryArg = input?.filter?.category ? eq(articles.categoryId, input.filter.category) : undefined; const sortConfig = input?.filter?.sort ?? "newest"; const { orderBy, cursor: cursorArg } = getArticleSorting( sortConfig, cursorObj, ); const user = ctx?.session?.user; const isEditor = user ? hasPermission(user.role, Role.EDITOR) : false; const publishedArg = !isEditor ? eq(articles.published, true) : undefined; const items = await ctx.db.query.articles.findMany({ where: and(cursorArg, categoryArg, queryFilterArg, publishedArg), limit: limit + 1, orderBy, columns: { title: true, slug: true, createdAt: true, published: isEditor ? true : false, }, }); let nextCursor: string | undefined = undefined; if (items.length > limit) { const cursorItem = items.pop(); // Create a cursor object with the relevant fields for sorting const cursorData: ArticleCursor = { slug: cursorItem!.slug, createdAt: cursorItem!.createdAt, title: cursorItem!.title, }; // Encode the cursor as base64 nextCursor = Buffer.from(SuperJSON.stringify(cursorData)).toString( "base64", ); } return { items, nextCursor, }; }), getMany: publicProcedure .input( z .object({ categoryId: z.string().optional(), limit: z.number().optional(), }) .optional(), ) .query(async ({ ctx, input }) => { const limit = input?.limit ?? 50; const where = and( input?.categoryId ? eq(articles.categoryId, input.categoryId) : undefined, eq(articles.published, true), ); return (await ctx.db.query.articles.findMany({ where, limit: limit, columns: { title: true, slug: true, createdAt: true, }, })) as Array
; }), getCount: publicProcedure .input(z.object({ categoryId: z.string() }).optional()) .query(async ({ ctx, input }) => { return ( await ctx.db .select({ count: count() }) .from(articles) .where( input?.categoryId ? eq(articles.categoryId, input.categoryId) : undefined, ) )[0]?.count; }), // mutations create: protectedProcedure .input(z.object({ article: createArticleSchema })) .mutation(async ({ ctx, input }) => { const isEditor = hasPermission(ctx.session.user.role, Role.EDITOR); if (!isEditor) { throw new Error("You are not allowed to create articles"); } const slug = generateSlug(input.article.title); return await ctx.db .insert(articles) .values({ ...input.article, slug }) .returning({ slug: articles.slug, }) .onConflictDoUpdate({ target: articles.slug, set: { slug: sql`${slug} || '-' || (SELECT COUNT(*) FROM ${articles} WHERE slug LIKE ${slug + "-%"})`, }, }); }), update: protectedProcedure .input(z.object({ article: articleSchema, articleId: z.string() })) .mutation(async ({ ctx, input }) => { const isEditor = hasPermission(ctx.session.user.role, Role.EDITOR); if (!isEditor) { throw new Error("You are not allowed to update articles"); } return await ctx.db .update(articles) .set(input.article) .where(eq(articles.id, input.articleId)) .returning({ slug: articles.slug, }); }), delete: protectedProcedure .input(z.object({ articleId: z.string() })) .mutation(async ({ ctx, input }) => { const isEditor = hasPermission(ctx.session.user.role, Role.EDITOR); if (!isEditor) { throw new Error("You are not allowed to delete articles"); } return await ctx.db .delete(articles) .where(eq(articles.id, input.articleId)) .returning({ id: articles.id, }); }), });