UNPKG

realworld-hono-drizzle

Version:
497 lines (438 loc) 12.7 kB
import { vValidator } from "@hono/valibot-validator"; import slugify from "@sindresorhus/slugify"; import { and, countDistinct, desc, eq, exists, getTableColumns, sql, } from "drizzle-orm"; import type { LibSQLDatabase } from "drizzle-orm/libsql"; import type { SQLiteColumn } from "drizzle-orm/sqlite-core"; import { Hono } from "hono"; import { decode } from "hono/jwt"; import { type InferOutput, array, parse, string } from "valibot"; import { JwtClaims, exposeToken, jwtAuth } from "../../auth.js"; import type * as schema from "../../db/schema.js"; import { articleFavoriteTable, articleTagTable, articlesTable, tagsTable, userFollowTable, usersTable, } from "../../db/schema.js"; import type { ThisAppEnv } from "../../factory.js"; import { amIFollowing } from "./am-i-following.js"; import { ArticleToCreate, MultipleArticlesResponse, SingleArticleResponse, UpdatedArticle, } from "./schema.js"; export const articlesModule = new Hono<ThisAppEnv>(); const TagList = array(string()); /** Subquery to include the `favorited` field on an article. */ function isFavorited({ db, articleSlug, me, }: { db: LibSQLDatabase<typeof schema>; articleSlug: SQLiteColumn; me: number; }) { return exists( db .select({ exists: sql`1` }) .from(articleFavoriteTable) .where( and( eq(articleFavoriteTable.articleSlug, articleSlug), eq(articleFavoriteTable.userId, me), ), ), ); } async function findArticle( db: LibSQLDatabase<typeof schema>, slug: string, self: InferOutput<typeof JwtClaims> | null, ) { const [article] = await db .select({ ...getTableColumns(articlesTable), favorited: (self === null ? sql<number>`0` : isFavorited({ db, articleSlug: articlesTable.slug, me: self.id }) ).mapWith(Boolean), favoritesCount: countDistinct(articleFavoriteTable.userId).as( "favoritesCount", ), tagList: sql<string>`json_group_array(DISTINCT ${articleTagTable.tag}) filter (where ${articleTagTable.tag} is not null)`.mapWith( (tagList) => parse(TagList, JSON.parse(tagList)), ), author: { username: usersTable.username, bio: usersTable.bio, image: usersTable.image, following: (self === null ? sql<number>`0` : amIFollowing({ db, them: articlesTable.authorId, me: self.id }) ).mapWith(Boolean), }, }) .from(articlesTable) .leftJoin( articleFavoriteTable, eq(articlesTable.slug, articleFavoriteTable.articleSlug), ) .leftJoin( articleTagTable, eq(articlesTable.slug, articleTagTable.articleSlug), ) .innerJoin(usersTable, eq(articlesTable.authorId, usersTable.id)) .where(eq(articlesTable.slug, slug)) .groupBy(articlesTable.slug); return article; } articlesModule.get("/", exposeToken, async (c) => { const db = c.get("db"); const token = c.get("token"); const self = token !== undefined ? parse(JwtClaims, decode(token).payload) : null; const tagFilter = c.req.query("tag"); const authorFilter = c.req.query("author"); const favoritedFilter = c.req.query("favorited"); const limit = Number(c.req.query("limit") ?? 20); const offset = Number(c.req.query("offset") ?? 0); const { body: _body, authorId: _authorId, ...desiredColumns } = getTableColumns(articlesTable); const slugsWithTagFilter = tagFilter ? db .select({ slug: articleTagTable.articleSlug }) .from(articleTagTable) .where(eq(articleTagTable.tag, tagFilter)) .as("slugsWithTagFilter") : null; const slugsWithFavoritedFilter = favoritedFilter ? db .select({ slug: articleFavoriteTable.articleSlug }) .from(articleFavoriteTable) .innerJoin(usersTable, eq(articleFavoriteTable.userId, usersTable.id)) .where(eq(usersTable.username, favoritedFilter)) .as("slugsWithFavoritedFilter") : null; const articles = await db .select({ ...desiredColumns, favorited: (self === null ? sql<number>`0` : isFavorited({ db, articleSlug: articlesTable.slug, me: self.id }) ).mapWith(Boolean), favoritesCount: countDistinct(articleFavoriteTable.userId).as( "favoritesCount", ), tagList: sql<string>`json_group_array(DISTINCT ${articleTagTable.tag}) filter (where ${articleTagTable.tag} is not null)`.mapWith( (tagList) => parse(TagList, JSON.parse(tagList)), ), author: { username: usersTable.username, bio: usersTable.bio, image: usersTable.image, following: (self === null ? sql<number>`0` : amIFollowing({ db, them: articlesTable.authorId, me: self.id }) ).mapWith(Boolean), }, }) .from(articlesTable) .leftJoin( articleFavoriteTable, eq(articlesTable.slug, articleFavoriteTable.articleSlug), ) .leftJoin( articleTagTable, eq(articlesTable.slug, articleTagTable.articleSlug), ) .innerJoin(usersTable, eq(articlesTable.authorId, usersTable.id)) .where( and( slugsWithTagFilter ? exists( db .select() .from(slugsWithTagFilter) .where(eq(slugsWithTagFilter.slug, articlesTable.slug)), ) : undefined, authorFilter ? eq(usersTable.username, authorFilter) : undefined, slugsWithFavoritedFilter ? exists( db .select() .from(slugsWithFavoritedFilter) .where(eq(slugsWithFavoritedFilter.slug, articlesTable.slug)), ) : undefined, ), ) .limit(limit) .offset(offset) .groupBy(articlesTable.slug) .orderBy(desc(articlesTable.createdAt)); const totalCountResult = await db .select({ count: countDistinct(articlesTable.slug) }) .from(articlesTable) .leftJoin( articleFavoriteTable, eq(articlesTable.slug, articleFavoriteTable.articleSlug), ) .leftJoin( articleTagTable, eq(articlesTable.slug, articleTagTable.articleSlug), ) .innerJoin(usersTable, eq(articlesTable.authorId, usersTable.id)) .where( and( slugsWithTagFilter ? exists( db .select() .from(slugsWithTagFilter) .where(eq(slugsWithTagFilter.slug, articlesTable.slug)), ) : undefined, authorFilter ? eq(usersTable.username, authorFilter) : undefined, slugsWithFavoritedFilter ? exists( db .select() .from(slugsWithFavoritedFilter) .where(eq(slugsWithFavoritedFilter.slug, articlesTable.slug)), ) : undefined, ), ); return c.json( parse(MultipleArticlesResponse, { articles, articlesCount: totalCountResult[0]?.count ?? 0, }), ); }); articlesModule.get("/feed", jwtAuth, async (c) => { const db = c.get("db"); const self = c.get("jwtPayload"); const limit = Number(c.req.query("limit") ?? 20); const offset = Number(c.req.query("offset") ?? 0); const { body: _body, authorId: _authorId, ...desiredColumns } = getTableColumns(articlesTable); const articles = await db .select({ ...desiredColumns, favorited: (self === null ? sql<number>`0` : isFavorited({ db, articleSlug: articlesTable.slug, me: self.id }) ).mapWith(Boolean), favoritesCount: countDistinct(articleFavoriteTable.userId).as( "favoritesCount", ), tagList: sql<string>`json_group_array(DISTINCT ${articleTagTable.tag}) filter (where ${articleTagTable.tag} is not null)`.mapWith( (tagList) => parse(TagList, JSON.parse(tagList)), ), author: { username: usersTable.username, bio: usersTable.bio, image: usersTable.image, following: (self === null ? sql<number>`0` : amIFollowing({ db, them: articlesTable.authorId, me: self.id }) ).mapWith(Boolean), }, }) .from(articlesTable) .leftJoin( articleFavoriteTable, eq(articlesTable.slug, articleFavoriteTable.articleSlug), ) .leftJoin( articleTagTable, eq(articlesTable.slug, articleTagTable.articleSlug), ) .innerJoin(usersTable, eq(articlesTable.authorId, usersTable.id)) .rightJoin( userFollowTable, eq(userFollowTable.followedId, articlesTable.authorId), ) .where(eq(userFollowTable.followerId, self.id)) .limit(limit) .offset(offset) .groupBy(articlesTable.slug) .orderBy(desc(articlesTable.createdAt)); const totalCountResult = await db .select({ count: countDistinct(articlesTable.slug) }) .from(articlesTable) .leftJoin( articleFavoriteTable, eq(articlesTable.slug, articleFavoriteTable.articleSlug), ) .leftJoin( articleTagTable, eq(articlesTable.slug, articleTagTable.articleSlug), ) .innerJoin(usersTable, eq(articlesTable.authorId, usersTable.id)) .rightJoin( userFollowTable, eq(userFollowTable.followedId, articlesTable.authorId), ) .where(eq(userFollowTable.followerId, self.id)); return c.json( parse(MultipleArticlesResponse, { articles, articlesCount: totalCountResult[0]?.count ?? 0, }), ); }); articlesModule.get("/:slug", exposeToken, async (c) => { const db = c.get("db"); const token = c.get("token"); const self = token !== undefined ? parse(JwtClaims, decode(token).payload) : null; const slug = c.req.param("slug"); const article = await findArticle(db, slug, self); if (article === undefined) { return c.notFound(); } return c.json(parse(SingleArticleResponse, { article })); }); articlesModule.post( "/", jwtAuth, vValidator("json", ArticleToCreate), async (c) => { const db = c.get("db"); const self = c.get("jwtPayload"); const { tagList, ...articlePayload } = c.req.valid("json").article; const slug = slugify(articlePayload.title); await db.insert(articlesTable).values({ slug, ...articlePayload, authorId: self.id, }); if (tagList !== undefined) { await db.insert(tagsTable).values(tagList.map((tag) => ({ tag }))); await db .insert(articleTagTable) .values(tagList.map((tag) => ({ articleSlug: slug, tag }))); } const article = await findArticle(db, slug, self); return c.json(parse(SingleArticleResponse, { article })); }, ); articlesModule.put( "/:slug", jwtAuth, vValidator("json", UpdatedArticle), async (c) => { const db = c.get("db"); const self = c.get("jwtPayload"); let slug = c.req.param("slug"); const { article: articlePayload } = c.req.valid("json"); const [articleOwnership] = await db .select({ isOwned: eq(articlesTable.authorId, self.id) }) .from(articlesTable) .where(eq(articlesTable.slug, slug)); if (articleOwnership === undefined) { return c.notFound(); } if (!articleOwnership.isOwned) { return new Response("Forbidden", { status: 403 }); } await db .update(articlesTable) .set(articlePayload) .where(eq(articlesTable.slug, slug)); if (articlePayload.title !== undefined) { const newSlug = slugify(articlePayload.title); await db .update(articlesTable) .set({ slug: newSlug }) .where(eq(articlesTable.slug, slug)); slug = newSlug; } const article = await findArticle(db, slug, self); return c.json(parse(SingleArticleResponse, { article })); }, ); articlesModule.delete("/:slug", jwtAuth, async (c) => { const db = c.get("db"); const self = c.get("jwtPayload"); const slug = c.req.param("slug"); const [articleOwnership] = await db .select({ isOwned: eq(articlesTable.authorId, self.id) }) .from(articlesTable) .where(eq(articlesTable.slug, slug)); if (articleOwnership === undefined) { return c.notFound(); } if (!articleOwnership.isOwned) { return new Response("Forbidden", { status: 403 }); } await db.delete(articlesTable).where(eq(articlesTable.slug, slug)); return new Response(null, { status: 204 }); }); articlesModule.post("/:slug/favorite", jwtAuth, async (c) => { const db = c.get("db"); const self = c.get("jwtPayload"); const slug = c.req.param("slug"); const [articleExists] = await db .select({ exists: sql`1` }) .from(articlesTable) .where(eq(articlesTable.slug, slug)); if (articleExists === undefined) { return c.notFound(); } await db .insert(articleFavoriteTable) .values({ articleSlug: slug, userId: self.id, }) .onConflictDoNothing(); const updatedArticle = await findArticle(db, slug, self); return c.json(parse(SingleArticleResponse, { article: updatedArticle })); }); articlesModule.delete("/:slug/favorite", jwtAuth, async (c) => { const db = c.get("db"); const self = c.get("jwtPayload"); const slug = c.req.param("slug"); const [articleExists] = await db .select({ exists: sql`1` }) .from(articlesTable) .where(eq(articlesTable.slug, slug)); if (articleExists === undefined) { return c.notFound(); } await db .delete(articleFavoriteTable) .where( and( eq(articleFavoriteTable.articleSlug, slug), eq(articleFavoriteTable.userId, self.id), ), ); const updatedArticle = await findArticle(db, slug, self); return c.json(parse(SingleArticleResponse, { article: updatedArticle })); });