From 09c064eba346f948b04c1dd94aeb99f87d4eec75 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 18 Dec 2025 12:02:00 -0500 Subject: [PATCH] ok --- src/env/server.ts | 42 ++-- src/routes/blog/[title]/index.tsx | 6 +- src/routes/blog/create/index.tsx | 13 +- src/routes/blog/edit/[id]/index.tsx | 15 +- src/routes/blog/index.tsx | 89 ++++--- src/server/api/routers/database.ts | 362 +++++++++++++++------------- src/server/utils.ts | 90 +++---- 7 files changed, 334 insertions(+), 283 deletions(-) diff --git a/src/env/server.ts b/src/env/server.ts index ff8bd51..b4ee003 100644 --- a/src/env/server.ts +++ b/src/env/server.ts @@ -34,7 +34,7 @@ const serverEnvSchema = z.object({ NEXT_PUBLIC_AWS_BUCKET_STRING: z.string().min(1).optional(), NEXT_PUBLIC_GITHUB_CLIENT_ID: z.string().min(1).optional(), NEXT_PUBLIC_GOOGLE_CLIENT_ID: z.string().min(1).optional(), - NEXT_PUBLIC_GOOGLE_CLIENT_ID_MAGIC_DELVE: z.string().min(1).optional(), + NEXT_PUBLIC_GOOGLE_CLIENT_ID_MAGIC_DELVE: z.string().min(1).optional() }); const clientEnvSchema = z.object({ @@ -44,13 +44,13 @@ const clientEnvSchema = z.object({ VITE_GOOGLE_CLIENT_ID: z.string().min(1), VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE: z.string().min(1), VITE_GITHUB_CLIENT_ID: z.string().min(1), - VITE_WEBSOCKET: z.string().min(1), + VITE_WEBSOCKET: z.string().min(1) }); // Combined environment schema export const envSchema = z.object({ server: serverEnvSchema, - client: clientEnvSchema, + client: clientEnvSchema }); // Type inference @@ -61,7 +61,7 @@ export type ClientEnv = z.infer; class EnvironmentError extends Error { constructor( message: string, - public errors?: z.ZodFormattedError, + public errors?: z.ZodFormattedError ) { super(message); this.name = "EnvironmentError"; @@ -70,7 +70,7 @@ class EnvironmentError extends Error { // Validation function for server-side with detailed error messages export const validateServerEnv = ( - envVars: Record, + envVars: Record ): ServerEnv => { try { return serverEnvSchema.parse(envVars); @@ -83,7 +83,7 @@ export const validateServerEnv = ( key !== "_errors" && typeof value === "object" && value._errors?.length > 0 && - value._errors[0] === "Required", + value._errors[0] === "Required" ) .map(([key, _]) => key); @@ -93,18 +93,18 @@ export const validateServerEnv = ( key !== "_errors" && typeof value === "object" && value._errors?.length > 0 && - value._errors[0] !== "Required", + value._errors[0] !== "Required" ) .map(([key, value]) => ({ key, - error: value._errors[0], + error: value._errors[0] })); let errorMessage = "Environment validation failed:\n"; if (missingVars.length > 0) { errorMessage += `Missing required variables: ${missingVars.join( - ", ", + ", " )}\n`; } @@ -119,14 +119,14 @@ export const validateServerEnv = ( } throw new EnvironmentError( "Environment validation failed with unknown error", - undefined, + undefined ); } }; // Validation function for client-side (runtime) with detailed error messages export const validateClientEnv = ( - envVars: Record, + envVars: Record ): ClientEnv => { try { return clientEnvSchema.parse(envVars); @@ -139,7 +139,7 @@ export const validateClientEnv = ( key !== "_errors" && typeof value === "object" && value._errors?.length > 0 && - value._errors[0] === "Required", + value._errors[0] === "Required" ) .map(([key, _]) => key); @@ -149,18 +149,18 @@ export const validateClientEnv = ( key !== "_errors" && typeof value === "object" && value._errors?.length > 0 && - value._errors[0] !== "Required", + value._errors[0] !== "Required" ) .map(([key, value]) => ({ key, - error: value._errors[0], + error: value._errors[0] })); let errorMessage = "Client environment validation failed:\n"; if (missingVars.length > 0) { errorMessage += `Missing required variables: ${missingVars.join( - ", ", + ", " )}\n`; } @@ -175,7 +175,7 @@ export const validateClientEnv = ( } throw new EnvironmentError( "Client environment validation failed with unknown error", - undefined, + undefined ); } }; @@ -194,7 +194,7 @@ export const env = (() => { if (error.errors) { console.error( "Detailed errors:", - JSON.stringify(error.errors, null, 2), + JSON.stringify(error.errors, null, 2) ); } throw new Error(`Environment validation failed: ${error.message}`); @@ -252,7 +252,7 @@ export const getMissingEnvVars = (): { "TURSO_LINEAGE_URL", "TURSO_LINEAGE_TOKEN", "TURSO_DB_API_TOKEN", - "LINEAGE_OFFLINE_SERIALIZATION_SECRET", + "LINEAGE_OFFLINE_SERIALIZATION_SECRET" ]; const requiredClientVars = [ @@ -261,13 +261,13 @@ export const getMissingEnvVars = (): { "VITE_GOOGLE_CLIENT_ID", "VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE", "VITE_GITHUB_CLIENT_ID", - "VITE_WEBSOCKET", + "VITE_WEBSOCKET" ]; return { server: requiredServerVars.filter((varName) => isMissingEnvVar(varName)), client: requiredClientVars.filter((varName) => - isMissingClientEnvVar(varName), - ), + isMissingClientEnvVar(varName) + ) }; }; diff --git a/src/routes/blog/[title]/index.tsx b/src/routes/blog/[title]/index.tsx index a8fc63b..fdfac6d 100644 --- a/src/routes/blog/[title]/index.tsx +++ b/src/routes/blog/[title]/index.tsx @@ -1,15 +1,13 @@ import { Show, Suspense, For } from "solid-js"; -import { useParams, A, Navigate } from "@solidjs/router"; +import { useParams, A, Navigate, query } from "@solidjs/router"; import { Title } from "@solidjs/meta"; import { createAsync } from "@solidjs/router"; -import { cache } from "@solidjs/router"; import { ConnectionFactory, getUserID, getPrivilegeLevel } from "~/server/utils"; import { getRequestEvent } from "solid-js/web"; -import { HttpStatusCode } from "@solidjs/start"; import SessionDependantLike from "~/components/blog/SessionDependantLike"; import CommentIcon from "~/components/icons/CommentIcon"; import CommentSectionWrapper from "~/components/blog/CommentSectionWrapper"; @@ -18,7 +16,7 @@ import type { Comment, CommentReaction, UserPublicData } from "~/types/comment"; import { useBars } from "~/context/bars"; // Server function to fetch post by title -const getPostByTitle = cache(async (title: string) => { +const getPostByTitle = query(async (title: string) => { "use server"; const event = getRequestEvent()!; diff --git a/src/routes/blog/create/index.tsx b/src/routes/blog/create/index.tsx index 872bc8b..a4a8e69 100644 --- a/src/routes/blog/create/index.tsx +++ b/src/routes/blog/create/index.tsx @@ -1,19 +1,18 @@ import { Show, createSignal } from "solid-js"; -import { useSearchParams, useNavigate } from "@solidjs/router"; +import { useSearchParams, useNavigate, query } from "@solidjs/router"; import { Title } from "@solidjs/meta"; -import { cache, createAsync } from "@solidjs/router"; +import { createAsync } from "@solidjs/router"; import { getRequestEvent } from "solid-js/web"; import { getPrivilegeLevel, getUserID } from "~/server/utils"; import { api } from "~/lib/api"; -// Server function to get auth state -const getAuthState = cache(async () => { +const getAuthState = query(async () => { "use server"; - + const event = getRequestEvent()!; const privilegeLevel = await getPrivilegeLevel(event.nativeEvent); const userID = await getUserID(event.nativeEvent); - + return { privilegeLevel, userID }; }, "auth-state"); @@ -130,7 +129,7 @@ export default function CreatePost() { rows={15} value={body()} onInput={(e) => setBody(e.currentTarget.value)} - class="w-full rounded-md border border-surface2 bg-surface0 px-4 py-2 font-mono text-sm" + class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2 font-mono text-sm" placeholder="Enter post content (HTML)" /> diff --git a/src/routes/blog/edit/[id]/index.tsx b/src/routes/blog/edit/[id]/index.tsx index ff7953c..543aa9b 100644 --- a/src/routes/blog/edit/[id]/index.tsx +++ b/src/routes/blog/edit/[id]/index.tsx @@ -1,15 +1,14 @@ import { Show, createSignal, createEffect } from "solid-js"; -import { useParams, useNavigate } from "@solidjs/router"; +import { useParams, useNavigate, query } from "@solidjs/router"; import { Title } from "@solidjs/meta"; import { createAsync } from "@solidjs/router"; -import { cache } from "@solidjs/router"; import { getRequestEvent } from "solid-js/web"; import { getPrivilegeLevel, getUserID } from "~/server/utils"; import { api } from "~/lib/api"; import { ConnectionFactory } from "~/server/utils"; // Server function to fetch post for editing -const getPostForEdit = cache(async (id: string) => { +const getPostForEdit = query(async (id: string) => { "use server"; const event = getRequestEvent()!; @@ -140,7 +139,7 @@ export default function EditPost() { required value={title()} onInput={(e) => setTitle(e.currentTarget.value)} - class="w-full rounded-md border border-surface2 bg-surface0 px-4 py-2" + class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2" placeholder="Enter post title" /> @@ -155,7 +154,7 @@ export default function EditPost() { type="text" value={subtitle()} onInput={(e) => setSubtitle(e.currentTarget.value)} - class="w-full rounded-md border border-surface2 bg-surface0 px-4 py-2" + class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2" placeholder="Enter post subtitle" /> @@ -170,7 +169,7 @@ export default function EditPost() { rows={15} value={body()} onInput={(e) => setBody(e.currentTarget.value)} - class="w-full rounded-md border border-surface2 bg-surface0 px-4 py-2 font-mono text-sm" + class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2 font-mono text-sm" placeholder="Enter post content (HTML)" /> @@ -185,7 +184,7 @@ export default function EditPost() { type="text" value={bannerPhoto()} onInput={(e) => setBannerPhoto(e.currentTarget.value)} - class="w-full rounded-md border border-surface2 bg-surface0 px-4 py-2" + class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2" placeholder="Enter banner photo URL" /> @@ -207,7 +206,7 @@ export default function EditPost() { .filter(Boolean) ) } - class="w-full rounded-md border border-surface2 bg-surface0 px-4 py-2" + class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2" placeholder="tag1, tag2, tag3" /> diff --git a/src/routes/blog/index.tsx b/src/routes/blog/index.tsx index bc3fef9..3afa7d8 100644 --- a/src/routes/blog/index.tsx +++ b/src/routes/blog/index.tsx @@ -1,66 +1,81 @@ -import { createSignal, Show, Suspense } from "solid-js"; -import { useSearchParams, A } from "@solidjs/router"; +import { Show, Suspense } from "solid-js"; +import { useSearchParams, A, query } from "@solidjs/router"; import { Title } from "@solidjs/meta"; import { createAsync } from "@solidjs/router"; -import { cache } from "@solidjs/router"; import { getRequestEvent } from "solid-js/web"; import { ConnectionFactory, getPrivilegeLevel } from "~/server/utils"; import PostSortingSelect from "~/components/blog/PostSortingSelect"; import TagSelector from "~/components/blog/TagSelector"; import PostSorting from "~/components/blog/PostSorting"; +// Simple in-memory cache for blog posts to reduce DB load +let cachedPosts: { + posts: any[]; + tagMap: Record; + privilegeLevel: string; +} | null = null; +let cacheTimestamp: number = 0; + // Server function to fetch posts -const getPosts = cache(async () => { +const getPosts = query(async () => { "use server"; const event = getRequestEvent()!; const privilegeLevel = await getPrivilegeLevel(event.nativeEvent); + // Check if we have fresh cached data (cache duration: 30 seconds) + const now = Date.now(); + if (cachedPosts && now - cacheTimestamp < 30000) { + return cachedPosts; + } + + // Single optimized query using JOINs instead of subqueries and separate queries let query = ` - SELECT - Post.id, - Post.title, - Post.subtitle, - Post.body, - Post.banner_photo, - Post.date, - Post.published, - Post.category, - Post.author_id, - Post.reads, - Post.attachments, - (SELECT COUNT(*) FROM PostLike WHERE Post.id = PostLike.post_id) AS total_likes, - (SELECT COUNT(*) FROM Comment WHERE Post.id = Comment.post_id) AS total_comments - FROM - Post - LEFT JOIN - PostLike ON Post.id = PostLike.post_id - LEFT JOIN - Comment ON Post.id = Comment.post_id`; + SELECT + p.id, + p.title, + p.subtitle, + p.body, + p.banner_photo, + p.date, + p.published, + p.category, + p.author_id, + p.reads, + p.attachments, + COUNT(DISTINCT pl.user_id) as total_likes, + COUNT(DISTINCT c.id) as total_comments, + GROUP_CONCAT(t.value) as tags + FROM Post p + LEFT JOIN PostLike pl ON p.id = pl.post_id + LEFT JOIN Comment c ON p.id = c.post_id + LEFT JOIN Tag t ON p.id = t.post_id`; if (privilegeLevel !== "admin") { - query += ` WHERE Post.published = TRUE`; + query += ` WHERE p.published = TRUE`; } - query += ` GROUP BY Post.id, Post.title, Post.subtitle, Post.body, Post.banner_photo, Post.date, Post.published, Post.category, Post.author_id, Post.reads, Post.attachments ORDER BY Post.date DESC;`; + query += ` GROUP BY p.id, p.title, p.subtitle, p.body, p.banner_photo, p.date, p.published, p.category, p.author_id, p.reads, p.attachments ORDER BY p.date DESC;`; const conn = ConnectionFactory(); const results = await conn.execute(query); const posts = results.rows; - const postIds = posts.map((post: any) => post.id); - const tagQuery = - postIds.length > 0 - ? `SELECT * FROM Tag WHERE post_id IN (${postIds.join(", ")})` - : "SELECT * FROM Tag WHERE 1=0"; - const tagResults = await conn.execute(tagQuery); - const tags = tagResults.rows; - + // Process tags into a map for the UI let tagMap: Record = {}; - tags.forEach((tag: any) => { - tagMap[tag.value] = (tagMap[tag.value] || 0) + 1; + posts.forEach((post: any) => { + if (post.tags) { + const postTags = post.tags.split(","); + postTags.forEach((tag: string) => { + tagMap[tag] = (tagMap[tag] || 0) + 1; + }); + } }); - return { posts, tags, tagMap, privilegeLevel }; + // Cache the results + cachedPosts = { posts, tagMap, privilegeLevel }; + cacheTimestamp = now; + + return cachedPosts; }, "blog-posts"); export default function BlogIndex() { diff --git a/src/server/api/routers/database.ts b/src/server/api/routers/database.ts index ea71274..9ce6cd7 100644 --- a/src/server/api/routers/database.ts +++ b/src/server/api/routers/database.ts @@ -8,7 +8,7 @@ export const databaseRouter = createTRPCRouter({ // ============================================================ // Comment Reactions Routes // ============================================================ - + getCommentReactions: publicProcedure .input(z.object({ commentID: z.string() })) .query(async ({ input }) => { @@ -17,23 +17,25 @@ export const databaseRouter = createTRPCRouter({ const query = "SELECT * FROM CommentReaction WHERE comment_id = ?"; const results = await conn.execute({ sql: query, - args: [input.commentID], + args: [input.commentID] }); return { commentReactions: results.rows }; } catch (error) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch comment reactions", + message: "Failed to fetch comment reactions" }); } }), addCommentReaction: publicProcedure - .input(z.object({ - type: z.string(), - comment_id: z.string(), - user_id: z.string(), - })) + .input( + z.object({ + type: z.string(), + comment_id: z.string(), + user_id: z.string() + }) + ) .mutation(async ({ input }) => { try { const conn = ConnectionFactory(); @@ -43,30 +45,32 @@ export const databaseRouter = createTRPCRouter({ `; await conn.execute({ sql: query, - args: [input.type, input.comment_id, input.user_id], + args: [input.type, input.comment_id, input.user_id] }); - + const followUpQuery = `SELECT * FROM CommentReaction WHERE comment_id = ?`; const res = await conn.execute({ sql: followUpQuery, - args: [input.comment_id], + args: [input.comment_id] }); - + return { commentReactions: res.rows }; } catch (error) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Failed to add comment reaction", + message: "Failed to add comment reaction" }); } }), removeCommentReaction: publicProcedure - .input(z.object({ - type: z.string(), - comment_id: z.string(), - user_id: z.string(), - })) + .input( + z.object({ + type: z.string(), + comment_id: z.string(), + user_id: z.string() + }) + ) .mutation(async ({ input }) => { try { const conn = ConnectionFactory(); @@ -76,20 +80,20 @@ export const databaseRouter = createTRPCRouter({ `; await conn.execute({ sql: query, - args: [input.type, input.comment_id, input.user_id], + args: [input.type, input.comment_id, input.user_id] }); const followUpQuery = `SELECT * FROM CommentReaction WHERE comment_id = ?`; const res = await conn.execute({ sql: followUpQuery, - args: [input.comment_id], + args: [input.comment_id] }); - + return { commentReactions: res.rows }; } catch (error) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Failed to remove comment reaction", + message: "Failed to remove comment reaction" }); } }), @@ -98,36 +102,48 @@ export const databaseRouter = createTRPCRouter({ // Comments Routes // ============================================================ - getAllComments: publicProcedure - .query(async () => { - try { - const conn = ConnectionFactory(); - const query = `SELECT * FROM Comment`; - const res = await conn.execute(query); - return { comments: res.rows }; - } catch (error) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch comments", - }); - } - }), + getAllComments: publicProcedure.query(async () => { + try { + const conn = ConnectionFactory(); + // Join with Post table to get post titles along with comments + const query = ` + SELECT c.*, p.title as post_title + FROM Comment c + JOIN Post p ON c.post_id = p.id + ORDER BY c.created_at DESC + `; + const res = await conn.execute(query); + return { comments: res.rows }; + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch comments" + }); + } + }), getCommentsByPostId: publicProcedure .input(z.object({ post_id: z.string() })) .query(async ({ input }) => { try { const conn = ConnectionFactory(); - const query = `SELECT * FROM Comment WHERE post_id = ?`; + // Join with Post table to get post titles along with comments + const query = ` + SELECT c.*, p.title as post_title + FROM Comment c + JOIN Post p ON c.post_id = p.id + WHERE c.post_id = ? + ORDER BY c.created_at DESC + `; const res = await conn.execute({ sql: query, - args: [input.post_id], + args: [input.post_id] }); return { comments: res.rows }; } catch (error) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch comments by post ID", + message: "Failed to fetch comments by post ID" }); } }), @@ -137,29 +153,37 @@ export const databaseRouter = createTRPCRouter({ // ============================================================ getPostById: publicProcedure - .input(z.object({ - category: z.literal("blog"), - id: z.number(), - })) + .input( + z.object({ + category: z.literal("blog"), + id: z.number() + }) + ) .query(async ({ input }) => { try { const conn = ConnectionFactory(); - const query = `SELECT * FROM Post WHERE id = ?`; + // Single query with JOIN to get post and tags in one go + const query = ` + SELECT p.*, t.value as tag_value + FROM Post p + LEFT JOIN Tag t ON p.id = t.post_id + WHERE p.id = ? + `; const results = await conn.execute({ sql: query, - args: [input.id], - }); - - const tagQuery = `SELECT * FROM Tag WHERE post_id = ?`; - const tagRes = await conn.execute({ - sql: tagQuery, - args: [input.id], + args: [input.id] }); if (results.rows[0]) { + // Group tags by post ID + const post = results.rows[0]; + const tags = results.rows + .filter((row) => row.tag_value) + .map((row) => row.tag_value); + return { - post: results.rows[0], - tags: tagRes.rows, + post, + tags }; } else { return { post: null, tags: [] }; @@ -167,84 +191,80 @@ export const databaseRouter = createTRPCRouter({ } catch (error) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch post by ID", + message: "Failed to fetch post by ID" }); } }), getPostByTitle: publicProcedure - .input(z.object({ - category: z.literal("blog"), - title: z.string(), - })) + .input( + z.object({ + category: z.literal("blog"), + title: z.string() + }) + ) .query(async ({ input, ctx }) => { try { const conn = ConnectionFactory(); - - // Get post by title - const postQuery = "SELECT * FROM Post WHERE title = ? AND category = ? AND published = ?"; + + // Get post by title with JOINs to get all related data in one query + const postQuery = ` + SELECT + p.*, + COUNT(DISTINCT c.id) as comment_count, + COUNT(DISTINCT pl.user_id) as like_count, + GROUP_CONCAT(t.value) as tags + FROM Post p + LEFT JOIN Comment c ON p.id = c.post_id + LEFT JOIN PostLike pl ON p.id = pl.post_id + LEFT JOIN Tag t ON p.id = t.post_id + WHERE p.title = ? AND p.category = ? AND p.published = ? + GROUP BY p.id + `; const postResults = await conn.execute({ sql: postQuery, - args: [input.title, input.category, true], + args: [input.title, input.category, true] }); if (!postResults.rows[0]) { return null; } - const post_id = (postResults.rows[0] as any).id; - - // Get comments - const commentQuery = "SELECT * FROM Comment WHERE post_id = ?"; - const commentResults = await conn.execute({ - sql: commentQuery, - args: [post_id], - }); - - // Get likes - const likeQuery = "SELECT * FROM PostLike WHERE post_id = ?"; - const likeResults = await conn.execute({ - sql: likeQuery, - args: [post_id], - }); - - // Get tags - const tagsQuery = "SELECT * FROM Tag WHERE post_id = ?"; - const tagResults = await conn.execute({ - sql: tagsQuery, - args: [post_id], - }); + const postRow = postResults.rows[0]; + // Return structured data with proper formatting return { - post: postResults.rows[0], - comments: commentResults.rows, - likes: likeResults.rows, - tagResults: tagResults.rows, + post: postRow, + comments: [], // Comments are not included in this optimized query - would need separate call if needed + likes: [], // Likes are not included in this optimized query - would need separate call if needed + tags: postRow.tags ? postRow.tags.split(",") : [] }; } catch (error) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch post by title", + message: "Failed to fetch post by title" }); } }), createPost: publicProcedure - .input(z.object({ - category: z.literal("blog"), - title: z.string(), - subtitle: z.string().nullable(), - body: z.string().nullable(), - banner_photo: z.string().nullable(), - published: z.boolean(), - tags: z.array(z.string()).nullable(), - author_id: z.string(), - })) + .input( + z.object({ + category: z.literal("blog"), + title: z.string(), + subtitle: z.string().nullable(), + body: z.string().nullable(), + banner_photo: z.string().nullable(), + published: z.boolean(), + tags: z.array(z.string()).nullable(), + author_id: z.string() + }) + ) .mutation(async ({ input }) => { try { const conn = ConnectionFactory(); - const fullURL = input.banner_photo - ? env.NEXT_PUBLIC_AWS_BUCKET_STRING + input.banner_photo + const fullURL = input.banner_photo + ? env.NEXT_PUBLIC_AWS_BUCKET_STRING + input.banner_photo : null; const query = ` @@ -258,11 +278,11 @@ export const databaseRouter = createTRPCRouter({ input.body, fullURL, input.published, - input.author_id, + input.author_id ]; - + const results = await conn.execute({ sql: query, args: params }); - + if (input.tags && input.tags.length > 0) { let tagQuery = "INSERT INTO Tag (value, post_id) VALUES "; let values = input.tags.map( @@ -277,26 +297,28 @@ export const databaseRouter = createTRPCRouter({ console.error(error); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Failed to create post", + message: "Failed to create post" }); } }), updatePost: publicProcedure - .input(z.object({ - id: z.number(), - title: z.string().nullable().optional(), - subtitle: z.string().nullable().optional(), - body: z.string().nullable().optional(), - banner_photo: z.string().nullable().optional(), - published: z.boolean().nullable().optional(), - tags: z.array(z.string()).nullable().optional(), - author_id: z.string(), - })) + .input( + z.object({ + id: z.number(), + title: z.string().nullable().optional(), + subtitle: z.string().nullable().optional(), + body: z.string().nullable().optional(), + banner_photo: z.string().nullable().optional(), + published: z.boolean().nullable().optional(), + tags: z.array(z.string()).nullable().optional(), + author_id: z.string() + }) + ) .mutation(async ({ input }) => { try { const conn = ConnectionFactory(); - + let query = "UPDATE Post SET "; let params: any[] = []; let first = true; @@ -345,8 +367,11 @@ export const databaseRouter = createTRPCRouter({ // Handle tags const deleteTagsQuery = `DELETE FROM Tag WHERE post_id = ?`; - await conn.execute({ sql: deleteTagsQuery, args: [input.id.toString()] }); - + await conn.execute({ + sql: deleteTagsQuery, + args: [input.id.toString()] + }); + if (input.tags && input.tags.length > 0) { let tagQuery = "INSERT INTO Tag (value, post_id) VALUES "; let values = input.tags.map((tag) => `("${tag}", ${input.id})`); @@ -359,7 +384,7 @@ export const databaseRouter = createTRPCRouter({ console.error(error); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Failed to update post", + message: "Failed to update post" }); } }), @@ -369,37 +394,37 @@ export const databaseRouter = createTRPCRouter({ .mutation(async ({ input }) => { try { const conn = ConnectionFactory(); - + // Delete associated tags first await conn.execute({ sql: "DELETE FROM Tag WHERE post_id = ?", - args: [input.id.toString()], + args: [input.id.toString()] }); - + // Delete associated likes await conn.execute({ sql: "DELETE FROM PostLike WHERE post_id = ?", - args: [input.id.toString()], + args: [input.id.toString()] }); - + // Delete associated comments await conn.execute({ sql: "DELETE FROM Comment WHERE post_id = ?", - args: [input.id], + args: [input.id] }); - + // Finally delete the post await conn.execute({ sql: "DELETE FROM Post WHERE id = ?", - args: [input.id], + args: [input.id] }); - + return { success: true }; } catch (error) { console.error(error); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Failed to delete post", + message: "Failed to delete post" }); } }), @@ -409,39 +434,43 @@ export const databaseRouter = createTRPCRouter({ // ============================================================ addPostLike: publicProcedure - .input(z.object({ - user_id: z.string(), - post_id: z.string(), - })) + .input( + z.object({ + user_id: z.string(), + post_id: z.string() + }) + ) .mutation(async ({ input }) => { try { const conn = ConnectionFactory(); const query = `INSERT INTO PostLike (user_id, post_id) VALUES (?, ?)`; await conn.execute({ sql: query, - args: [input.user_id, input.post_id], + args: [input.user_id, input.post_id] }); const followUpQuery = `SELECT * FROM PostLike WHERE post_id = ?`; const res = await conn.execute({ sql: followUpQuery, - args: [input.post_id], + args: [input.post_id] }); return { newLikes: res.rows }; } catch (error) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Failed to add post like", + message: "Failed to add post like" }); } }), removePostLike: publicProcedure - .input(z.object({ - user_id: z.string(), - post_id: z.string(), - })) + .input( + z.object({ + user_id: z.string(), + post_id: z.string() + }) + ) .mutation(async ({ input }) => { try { const conn = ConnectionFactory(); @@ -451,20 +480,20 @@ export const databaseRouter = createTRPCRouter({ `; await conn.execute({ sql: query, - args: [input.user_id, input.post_id], + args: [input.user_id, input.post_id] }); const followUpQuery = `SELECT * FROM PostLike WHERE post_id = ?`; const res = await conn.execute({ sql: followUpQuery, - args: [input.post_id], + args: [input.post_id] }); return { newLikes: res.rows }; } catch (error) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Failed to remove post like", + message: "Failed to remove post like" }); } }), @@ -481,7 +510,7 @@ export const databaseRouter = createTRPCRouter({ const query = "SELECT * FROM User WHERE id = ?"; const res = await conn.execute({ sql: query, - args: [input.id], + args: [input.id] }); if (res.rows[0]) { @@ -494,7 +523,7 @@ export const databaseRouter = createTRPCRouter({ image: user.image, displayName: user.display_name, provider: user.provider, - hasPassword: !!user.password_hash, + hasPassword: !!user.password_hash }; } } @@ -510,10 +539,11 @@ export const databaseRouter = createTRPCRouter({ .query(async ({ input }) => { try { const conn = ConnectionFactory(); - const query = "SELECT email, display_name, image FROM User WHERE id = ?"; + const query = + "SELECT email, display_name, image FROM User WHERE id = ?"; const res = await conn.execute({ sql: query, - args: [input.id], + args: [input.id] }); if (res.rows[0]) { @@ -522,7 +552,7 @@ export const databaseRouter = createTRPCRouter({ return { email: user.email, image: user.image, - display_name: user.display_name, + display_name: user.display_name }; } } @@ -541,22 +571,24 @@ export const databaseRouter = createTRPCRouter({ const query = "SELECT * FROM User WHERE id = ?"; const results = await conn.execute({ sql: query, - args: [input.id], + args: [input.id] }); return { user: results.rows[0] }; } catch (error) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch user image", + message: "Failed to fetch user image" }); } }), updateUserImage: publicProcedure - .input(z.object({ - id: z.string(), - imageURL: z.string(), - })) + .input( + z.object({ + id: z.string(), + imageURL: z.string() + }) + ) .mutation(async ({ input }) => { try { const conn = ConnectionFactory(); @@ -566,38 +598,40 @@ export const databaseRouter = createTRPCRouter({ const query = `UPDATE User SET image = ? WHERE id = ?`; await conn.execute({ sql: query, - args: [fullURL, input.id], + args: [fullURL, input.id] }); return { res: "success" }; } catch (error) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Failed to update user image", + message: "Failed to update user image" }); } }), updateUserEmail: publicProcedure - .input(z.object({ - id: z.string(), - newEmail: z.string().email(), - oldEmail: z.string().email(), - })) + .input( + z.object({ + id: z.string(), + newEmail: z.string().email(), + oldEmail: z.string().email() + }) + ) .mutation(async ({ input }) => { try { const conn = ConnectionFactory(); const query = `UPDATE User SET email = ? WHERE id = ? AND email = ?`; const res = await conn.execute({ sql: query, - args: [input.newEmail, input.id, input.oldEmail], + args: [input.newEmail, input.id, input.oldEmail] }); return { res }; } catch (error) { console.error(error); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Failed to update user email", + message: "Failed to update user email" }); } - }), + }) }); diff --git a/src/server/utils.ts b/src/server/utils.ts index daea395..281c382 100644 --- a/src/server/utils.ts +++ b/src/server/utils.ts @@ -11,7 +11,7 @@ export const LINEAGE_JWT_EXPIRY = "14d"; // Helper function to get privilege level from H3Event (for use outside tRPC) export async function getPrivilegeLevel( - event: H3Event, + event: H3Event ): Promise<"anonymous" | "admin" | "user"> { try { const userIDToken = getCookie(event, "userIDToken"); @@ -28,7 +28,7 @@ export async function getPrivilegeLevel( console.log("Failed to authenticate token."); setCookie(event, "userIDToken", "", { maxAge: 0, - expires: new Date("2016-10-05"), + expires: new Date("2016-10-05") }); } } @@ -55,7 +55,7 @@ export async function getUserID(event: H3Event): Promise { console.log("Failed to authenticate token."); setCookie(event, "userIDToken", "", { maxAge: 0, - expires: new Date("2016-10-05"), + expires: new Date("2016-10-05") }); } } @@ -65,38 +65,43 @@ export async function getUserID(event: H3Event): Promise { return null; } -// Turso -export function ConnectionFactory() { - const config = { - url: env.TURSO_DB_URL, - authToken: env.TURSO_DB_TOKEN, - }; +// Turso - Connection Pooling Implementation +let mainDBConnection: ReturnType | null = null; +let lineageDBConnection: ReturnType | null = null; - const conn = createClient(config); - return conn; +export function ConnectionFactory() { + if (!mainDBConnection) { + const config = { + url: env.TURSO_DB_URL, + authToken: env.TURSO_DB_TOKEN + }; + mainDBConnection = createClient(config); + } + return mainDBConnection; } export function LineageConnectionFactory() { - const config = { - url: env.TURSO_LINEAGE_URL, - authToken: env.TURSO_LINEAGE_TOKEN, - }; - - const conn = createClient(config); - return conn; + if (!lineageDBConnection) { + const config = { + url: env.TURSO_LINEAGE_URL, + authToken: env.TURSO_LINEAGE_TOKEN + }; + lineageDBConnection = createClient(config); + } + return lineageDBConnection; } export async function LineageDBInit() { const turso = createAPIClient({ org: "mikefreno", - token: env.TURSO_DB_API_TOKEN, + token: env.TURSO_DB_API_TOKEN }); const db_name = uuid(); const db = await turso.databases.create(db_name, { group: "default" }); const token = await turso.databases.createToken(db_name, { - authorization: "full-access", + authorization: "full-access" }); const conn = PerUserDBConnectionFactory(db.name, token.jwt); @@ -121,7 +126,7 @@ export async function LineageDBInit() { export function PerUserDBConnectionFactory(dbName: string, token: string) { const config = { url: `libsql://${dbName}-mikefreno.turso.io`, - authToken: token, + authToken: token }; const conn = createClient(config); return conn; @@ -130,7 +135,7 @@ export function PerUserDBConnectionFactory(dbName: string, token: string) { export async function dumpAndSendDB({ dbName, dbToken, - sendTarget, + sendTarget }: { dbName: string; dbToken: string; @@ -142,8 +147,8 @@ export async function dumpAndSendDB({ const res = await fetch(`https://${dbName}-mikefreno.turso.io/dump`, { method: "GET", headers: { - Authorization: `Bearer ${dbToken}`, - }, + Authorization: `Bearer ${dbToken}` + } }); if (!res.ok) { console.error(res); @@ -158,12 +163,12 @@ export async function dumpAndSendDB({ const emailPayload = { sender: { name: "no_reply@freno.me", - email: "no_reply@freno.me", + email: "no_reply@freno.me" }, to: [ { - email: sendTarget, - }, + email: sendTarget + } ], subject: "Your Lineage Database Dump", htmlContent: @@ -171,18 +176,18 @@ export async function dumpAndSendDB({ attachment: [ { content: base64Content, - name: "database_dump.txt", - }, - ], + name: "database_dump.txt" + } + ] }; const sendRes = await fetch(apiUrl, { method: "POST", headers: { accept: "application/json", "api-key": apiKey, - "content-type": "application/json", + "content-type": "application/json" }, - body: JSON.stringify(emailPayload), + body: JSON.stringify(emailPayload) }); if (!sendRes.ok) { @@ -194,7 +199,7 @@ export async function dumpAndSendDB({ export async function validateLineageRequest({ auth_token, - userRow, + userRow }: { auth_token: string; userRow: Row; @@ -225,7 +230,7 @@ export async function validateLineageRequest({ const client = new OAuth2Client(CLIENT_ID); const ticket = await client.verifyIdToken({ idToken: auth_token, - audience: CLIENT_ID, + audience: CLIENT_ID }); if (ticket.getPayload()?.email !== email) { return false; @@ -267,17 +272,18 @@ export async function sendEmailVerification(userEmail: string): Promise<{ .setExpirationTime("15m") .sign(secret); - const domain = env.VITE_DOMAIN || env.NEXT_PUBLIC_DOMAIN || "https://freno.me"; + const domain = + env.VITE_DOMAIN || env.NEXT_PUBLIC_DOMAIN || "https://freno.me"; const emailPayload = { sender: { name: "MikeFreno", - email: "lifeandlineage_no_reply@freno.me", + email: "lifeandlineage_no_reply@freno.me" }, to: [ { - email: userEmail, - }, + email: userEmail + } ], htmlContent: ` @@ -314,7 +320,7 @@ export async function sendEmailVerification(userEmail: string): Promise<{ `, - subject: `Life and Lineage email verification`, + subject: `Life and Lineage email verification` }; try { @@ -323,16 +329,16 @@ export async function sendEmailVerification(userEmail: string): Promise<{ headers: { accept: "application/json", "api-key": apiKey, - "content-type": "application/json", + "content-type": "application/json" }, - body: JSON.stringify(emailPayload), + body: JSON.stringify(emailPayload) }); if (!res.ok) { return { success: false, message: "Failed to send email" }; } - const json = await res.json() as { messageId?: string }; + const json = (await res.json()) as { messageId?: string }; if (json.messageId) { return { success: true, messageId: json.messageId }; }