This commit is contained in:
Michael Freno
2025-12-18 12:02:00 -05:00
parent a19ad0cb36
commit 09c064eba3
7 changed files with 334 additions and 283 deletions

42
src/env/server.ts vendored
View File

@@ -34,7 +34,7 @@ const serverEnvSchema = z.object({
NEXT_PUBLIC_AWS_BUCKET_STRING: z.string().min(1).optional(), NEXT_PUBLIC_AWS_BUCKET_STRING: z.string().min(1).optional(),
NEXT_PUBLIC_GITHUB_CLIENT_ID: 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: 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({ const clientEnvSchema = z.object({
@@ -44,13 +44,13 @@ const clientEnvSchema = z.object({
VITE_GOOGLE_CLIENT_ID: z.string().min(1), VITE_GOOGLE_CLIENT_ID: z.string().min(1),
VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE: z.string().min(1), VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE: z.string().min(1),
VITE_GITHUB_CLIENT_ID: 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 // Combined environment schema
export const envSchema = z.object({ export const envSchema = z.object({
server: serverEnvSchema, server: serverEnvSchema,
client: clientEnvSchema, client: clientEnvSchema
}); });
// Type inference // Type inference
@@ -61,7 +61,7 @@ export type ClientEnv = z.infer<typeof clientEnvSchema>;
class EnvironmentError extends Error { class EnvironmentError extends Error {
constructor( constructor(
message: string, message: string,
public errors?: z.ZodFormattedError<any>, public errors?: z.ZodFormattedError<any>
) { ) {
super(message); super(message);
this.name = "EnvironmentError"; this.name = "EnvironmentError";
@@ -70,7 +70,7 @@ class EnvironmentError extends Error {
// Validation function for server-side with detailed error messages // Validation function for server-side with detailed error messages
export const validateServerEnv = ( export const validateServerEnv = (
envVars: Record<string, string | undefined>, envVars: Record<string, string | undefined>
): ServerEnv => { ): ServerEnv => {
try { try {
return serverEnvSchema.parse(envVars); return serverEnvSchema.parse(envVars);
@@ -83,7 +83,7 @@ export const validateServerEnv = (
key !== "_errors" && key !== "_errors" &&
typeof value === "object" && typeof value === "object" &&
value._errors?.length > 0 && value._errors?.length > 0 &&
value._errors[0] === "Required", value._errors[0] === "Required"
) )
.map(([key, _]) => key); .map(([key, _]) => key);
@@ -93,18 +93,18 @@ export const validateServerEnv = (
key !== "_errors" && key !== "_errors" &&
typeof value === "object" && typeof value === "object" &&
value._errors?.length > 0 && value._errors?.length > 0 &&
value._errors[0] !== "Required", value._errors[0] !== "Required"
) )
.map(([key, value]) => ({ .map(([key, value]) => ({
key, key,
error: value._errors[0], error: value._errors[0]
})); }));
let errorMessage = "Environment validation failed:\n"; let errorMessage = "Environment validation failed:\n";
if (missingVars.length > 0) { if (missingVars.length > 0) {
errorMessage += `Missing required variables: ${missingVars.join( errorMessage += `Missing required variables: ${missingVars.join(
", ", ", "
)}\n`; )}\n`;
} }
@@ -119,14 +119,14 @@ export const validateServerEnv = (
} }
throw new EnvironmentError( throw new EnvironmentError(
"Environment validation failed with unknown error", "Environment validation failed with unknown error",
undefined, undefined
); );
} }
}; };
// Validation function for client-side (runtime) with detailed error messages // Validation function for client-side (runtime) with detailed error messages
export const validateClientEnv = ( export const validateClientEnv = (
envVars: Record<string, string | undefined>, envVars: Record<string, string | undefined>
): ClientEnv => { ): ClientEnv => {
try { try {
return clientEnvSchema.parse(envVars); return clientEnvSchema.parse(envVars);
@@ -139,7 +139,7 @@ export const validateClientEnv = (
key !== "_errors" && key !== "_errors" &&
typeof value === "object" && typeof value === "object" &&
value._errors?.length > 0 && value._errors?.length > 0 &&
value._errors[0] === "Required", value._errors[0] === "Required"
) )
.map(([key, _]) => key); .map(([key, _]) => key);
@@ -149,18 +149,18 @@ export const validateClientEnv = (
key !== "_errors" && key !== "_errors" &&
typeof value === "object" && typeof value === "object" &&
value._errors?.length > 0 && value._errors?.length > 0 &&
value._errors[0] !== "Required", value._errors[0] !== "Required"
) )
.map(([key, value]) => ({ .map(([key, value]) => ({
key, key,
error: value._errors[0], error: value._errors[0]
})); }));
let errorMessage = "Client environment validation failed:\n"; let errorMessage = "Client environment validation failed:\n";
if (missingVars.length > 0) { if (missingVars.length > 0) {
errorMessage += `Missing required variables: ${missingVars.join( errorMessage += `Missing required variables: ${missingVars.join(
", ", ", "
)}\n`; )}\n`;
} }
@@ -175,7 +175,7 @@ export const validateClientEnv = (
} }
throw new EnvironmentError( throw new EnvironmentError(
"Client environment validation failed with unknown error", "Client environment validation failed with unknown error",
undefined, undefined
); );
} }
}; };
@@ -194,7 +194,7 @@ export const env = (() => {
if (error.errors) { if (error.errors) {
console.error( console.error(
"Detailed errors:", "Detailed errors:",
JSON.stringify(error.errors, null, 2), JSON.stringify(error.errors, null, 2)
); );
} }
throw new Error(`Environment validation failed: ${error.message}`); throw new Error(`Environment validation failed: ${error.message}`);
@@ -252,7 +252,7 @@ export const getMissingEnvVars = (): {
"TURSO_LINEAGE_URL", "TURSO_LINEAGE_URL",
"TURSO_LINEAGE_TOKEN", "TURSO_LINEAGE_TOKEN",
"TURSO_DB_API_TOKEN", "TURSO_DB_API_TOKEN",
"LINEAGE_OFFLINE_SERIALIZATION_SECRET", "LINEAGE_OFFLINE_SERIALIZATION_SECRET"
]; ];
const requiredClientVars = [ const requiredClientVars = [
@@ -261,13 +261,13 @@ export const getMissingEnvVars = (): {
"VITE_GOOGLE_CLIENT_ID", "VITE_GOOGLE_CLIENT_ID",
"VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE", "VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE",
"VITE_GITHUB_CLIENT_ID", "VITE_GITHUB_CLIENT_ID",
"VITE_WEBSOCKET", "VITE_WEBSOCKET"
]; ];
return { return {
server: requiredServerVars.filter((varName) => isMissingEnvVar(varName)), server: requiredServerVars.filter((varName) => isMissingEnvVar(varName)),
client: requiredClientVars.filter((varName) => client: requiredClientVars.filter((varName) =>
isMissingClientEnvVar(varName), isMissingClientEnvVar(varName)
), )
}; };
}; };

View File

@@ -1,15 +1,13 @@
import { Show, Suspense, For } from "solid-js"; 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 { Title } from "@solidjs/meta";
import { createAsync } from "@solidjs/router"; import { createAsync } from "@solidjs/router";
import { cache } from "@solidjs/router";
import { import {
ConnectionFactory, ConnectionFactory,
getUserID, getUserID,
getPrivilegeLevel getPrivilegeLevel
} from "~/server/utils"; } from "~/server/utils";
import { getRequestEvent } from "solid-js/web"; import { getRequestEvent } from "solid-js/web";
import { HttpStatusCode } from "@solidjs/start";
import SessionDependantLike from "~/components/blog/SessionDependantLike"; import SessionDependantLike from "~/components/blog/SessionDependantLike";
import CommentIcon from "~/components/icons/CommentIcon"; import CommentIcon from "~/components/icons/CommentIcon";
import CommentSectionWrapper from "~/components/blog/CommentSectionWrapper"; import CommentSectionWrapper from "~/components/blog/CommentSectionWrapper";
@@ -18,7 +16,7 @@ import type { Comment, CommentReaction, UserPublicData } from "~/types/comment";
import { useBars } from "~/context/bars"; import { useBars } from "~/context/bars";
// Server function to fetch post by title // Server function to fetch post by title
const getPostByTitle = cache(async (title: string) => { const getPostByTitle = query(async (title: string) => {
"use server"; "use server";
const event = getRequestEvent()!; const event = getRequestEvent()!;

View File

@@ -1,19 +1,18 @@
import { Show, createSignal } from "solid-js"; 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 { Title } from "@solidjs/meta";
import { cache, createAsync } from "@solidjs/router"; import { createAsync } from "@solidjs/router";
import { getRequestEvent } from "solid-js/web"; import { getRequestEvent } from "solid-js/web";
import { getPrivilegeLevel, getUserID } from "~/server/utils"; import { getPrivilegeLevel, getUserID } from "~/server/utils";
import { api } from "~/lib/api"; import { api } from "~/lib/api";
// Server function to get auth state const getAuthState = query(async () => {
const getAuthState = cache(async () => {
"use server"; "use server";
const event = getRequestEvent()!; const event = getRequestEvent()!;
const privilegeLevel = await getPrivilegeLevel(event.nativeEvent); const privilegeLevel = await getPrivilegeLevel(event.nativeEvent);
const userID = await getUserID(event.nativeEvent); const userID = await getUserID(event.nativeEvent);
return { privilegeLevel, userID }; return { privilegeLevel, userID };
}, "auth-state"); }, "auth-state");
@@ -130,7 +129,7 @@ export default function CreatePost() {
rows={15} rows={15}
value={body()} value={body()}
onInput={(e) => setBody(e.currentTarget.value)} 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)" placeholder="Enter post content (HTML)"
/> />
</div> </div>

View File

@@ -1,15 +1,14 @@
import { Show, createSignal, createEffect } from "solid-js"; 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 { Title } from "@solidjs/meta";
import { createAsync } from "@solidjs/router"; import { createAsync } from "@solidjs/router";
import { cache } from "@solidjs/router";
import { getRequestEvent } from "solid-js/web"; import { getRequestEvent } from "solid-js/web";
import { getPrivilegeLevel, getUserID } from "~/server/utils"; import { getPrivilegeLevel, getUserID } from "~/server/utils";
import { api } from "~/lib/api"; import { api } from "~/lib/api";
import { ConnectionFactory } from "~/server/utils"; import { ConnectionFactory } from "~/server/utils";
// Server function to fetch post for editing // Server function to fetch post for editing
const getPostForEdit = cache(async (id: string) => { const getPostForEdit = query(async (id: string) => {
"use server"; "use server";
const event = getRequestEvent()!; const event = getRequestEvent()!;
@@ -140,7 +139,7 @@ export default function EditPost() {
required required
value={title()} value={title()}
onInput={(e) => setTitle(e.currentTarget.value)} 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" placeholder="Enter post title"
/> />
</div> </div>
@@ -155,7 +154,7 @@ export default function EditPost() {
type="text" type="text"
value={subtitle()} value={subtitle()}
onInput={(e) => setSubtitle(e.currentTarget.value)} 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" placeholder="Enter post subtitle"
/> />
</div> </div>
@@ -170,7 +169,7 @@ export default function EditPost() {
rows={15} rows={15}
value={body()} value={body()}
onInput={(e) => setBody(e.currentTarget.value)} 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)" placeholder="Enter post content (HTML)"
/> />
</div> </div>
@@ -185,7 +184,7 @@ export default function EditPost() {
type="text" type="text"
value={bannerPhoto()} value={bannerPhoto()}
onInput={(e) => setBannerPhoto(e.currentTarget.value)} 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" placeholder="Enter banner photo URL"
/> />
</div> </div>
@@ -207,7 +206,7 @@ export default function EditPost() {
.filter(Boolean) .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" placeholder="tag1, tag2, tag3"
/> />
</div> </div>

View File

@@ -1,66 +1,81 @@
import { createSignal, Show, Suspense } from "solid-js"; import { Show, Suspense } from "solid-js";
import { useSearchParams, A } from "@solidjs/router"; import { useSearchParams, A, query } from "@solidjs/router";
import { Title } from "@solidjs/meta"; import { Title } from "@solidjs/meta";
import { createAsync } from "@solidjs/router"; import { createAsync } from "@solidjs/router";
import { cache } from "@solidjs/router";
import { getRequestEvent } from "solid-js/web"; import { getRequestEvent } from "solid-js/web";
import { ConnectionFactory, getPrivilegeLevel } from "~/server/utils"; import { ConnectionFactory, getPrivilegeLevel } from "~/server/utils";
import PostSortingSelect from "~/components/blog/PostSortingSelect"; import PostSortingSelect from "~/components/blog/PostSortingSelect";
import TagSelector from "~/components/blog/TagSelector"; import TagSelector from "~/components/blog/TagSelector";
import PostSorting from "~/components/blog/PostSorting"; import PostSorting from "~/components/blog/PostSorting";
// Simple in-memory cache for blog posts to reduce DB load
let cachedPosts: {
posts: any[];
tagMap: Record<string, number>;
privilegeLevel: string;
} | null = null;
let cacheTimestamp: number = 0;
// Server function to fetch posts // Server function to fetch posts
const getPosts = cache(async () => { const getPosts = query(async () => {
"use server"; "use server";
const event = getRequestEvent()!; const event = getRequestEvent()!;
const privilegeLevel = await getPrivilegeLevel(event.nativeEvent); 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 = ` let query = `
SELECT SELECT
Post.id, p.id,
Post.title, p.title,
Post.subtitle, p.subtitle,
Post.body, p.body,
Post.banner_photo, p.banner_photo,
Post.date, p.date,
Post.published, p.published,
Post.category, p.category,
Post.author_id, p.author_id,
Post.reads, p.reads,
Post.attachments, p.attachments,
(SELECT COUNT(*) FROM PostLike WHERE Post.id = PostLike.post_id) AS total_likes, COUNT(DISTINCT pl.user_id) as total_likes,
(SELECT COUNT(*) FROM Comment WHERE Post.id = Comment.post_id) AS total_comments COUNT(DISTINCT c.id) as total_comments,
FROM GROUP_CONCAT(t.value) as tags
Post FROM Post p
LEFT JOIN LEFT JOIN PostLike pl ON p.id = pl.post_id
PostLike ON Post.id = PostLike.post_id LEFT JOIN Comment c ON p.id = c.post_id
LEFT JOIN LEFT JOIN Tag t ON p.id = t.post_id`;
Comment ON Post.id = Comment.post_id`;
if (privilegeLevel !== "admin") { 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 conn = ConnectionFactory();
const results = await conn.execute(query); const results = await conn.execute(query);
const posts = results.rows; const posts = results.rows;
const postIds = posts.map((post: any) => post.id); // Process tags into a map for the UI
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;
let tagMap: Record<string, number> = {}; let tagMap: Record<string, number> = {};
tags.forEach((tag: any) => { posts.forEach((post: any) => {
tagMap[tag.value] = (tagMap[tag.value] || 0) + 1; 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"); }, "blog-posts");
export default function BlogIndex() { export default function BlogIndex() {

View File

@@ -8,7 +8,7 @@ export const databaseRouter = createTRPCRouter({
// ============================================================ // ============================================================
// Comment Reactions Routes // Comment Reactions Routes
// ============================================================ // ============================================================
getCommentReactions: publicProcedure getCommentReactions: publicProcedure
.input(z.object({ commentID: z.string() })) .input(z.object({ commentID: z.string() }))
.query(async ({ input }) => { .query(async ({ input }) => {
@@ -17,23 +17,25 @@ export const databaseRouter = createTRPCRouter({
const query = "SELECT * FROM CommentReaction WHERE comment_id = ?"; const query = "SELECT * FROM CommentReaction WHERE comment_id = ?";
const results = await conn.execute({ const results = await conn.execute({
sql: query, sql: query,
args: [input.commentID], args: [input.commentID]
}); });
return { commentReactions: results.rows }; return { commentReactions: results.rows };
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch comment reactions", message: "Failed to fetch comment reactions"
}); });
} }
}), }),
addCommentReaction: publicProcedure addCommentReaction: publicProcedure
.input(z.object({ .input(
type: z.string(), z.object({
comment_id: z.string(), type: z.string(),
user_id: z.string(), comment_id: z.string(),
})) user_id: z.string()
})
)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
@@ -43,30 +45,32 @@ export const databaseRouter = createTRPCRouter({
`; `;
await conn.execute({ await conn.execute({
sql: query, 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 followUpQuery = `SELECT * FROM CommentReaction WHERE comment_id = ?`;
const res = await conn.execute({ const res = await conn.execute({
sql: followUpQuery, sql: followUpQuery,
args: [input.comment_id], args: [input.comment_id]
}); });
return { commentReactions: res.rows }; return { commentReactions: res.rows };
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to add comment reaction", message: "Failed to add comment reaction"
}); });
} }
}), }),
removeCommentReaction: publicProcedure removeCommentReaction: publicProcedure
.input(z.object({ .input(
type: z.string(), z.object({
comment_id: z.string(), type: z.string(),
user_id: z.string(), comment_id: z.string(),
})) user_id: z.string()
})
)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
@@ -76,20 +80,20 @@ export const databaseRouter = createTRPCRouter({
`; `;
await conn.execute({ await conn.execute({
sql: query, 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 followUpQuery = `SELECT * FROM CommentReaction WHERE comment_id = ?`;
const res = await conn.execute({ const res = await conn.execute({
sql: followUpQuery, sql: followUpQuery,
args: [input.comment_id], args: [input.comment_id]
}); });
return { commentReactions: res.rows }; return { commentReactions: res.rows };
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", 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 // Comments Routes
// ============================================================ // ============================================================
getAllComments: publicProcedure getAllComments: publicProcedure.query(async () => {
.query(async () => { try {
try { const conn = ConnectionFactory();
const conn = ConnectionFactory(); // Join with Post table to get post titles along with comments
const query = `SELECT * FROM Comment`; const query = `
const res = await conn.execute(query); SELECT c.*, p.title as post_title
return { comments: res.rows }; FROM Comment c
} catch (error) { JOIN Post p ON c.post_id = p.id
throw new TRPCError({ ORDER BY c.created_at DESC
code: "INTERNAL_SERVER_ERROR", `;
message: "Failed to fetch comments", 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 getCommentsByPostId: publicProcedure
.input(z.object({ post_id: z.string() })) .input(z.object({ post_id: z.string() }))
.query(async ({ input }) => { .query(async ({ input }) => {
try { try {
const conn = ConnectionFactory(); 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({ const res = await conn.execute({
sql: query, sql: query,
args: [input.post_id], args: [input.post_id]
}); });
return { comments: res.rows }; return { comments: res.rows };
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", 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 getPostById: publicProcedure
.input(z.object({ .input(
category: z.literal("blog"), z.object({
id: z.number(), category: z.literal("blog"),
})) id: z.number()
})
)
.query(async ({ input }) => { .query(async ({ input }) => {
try { try {
const conn = ConnectionFactory(); 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({ const results = await conn.execute({
sql: query, sql: query,
args: [input.id], args: [input.id]
});
const tagQuery = `SELECT * FROM Tag WHERE post_id = ?`;
const tagRes = await conn.execute({
sql: tagQuery,
args: [input.id],
}); });
if (results.rows[0]) { 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 { return {
post: results.rows[0], post,
tags: tagRes.rows, tags
}; };
} else { } else {
return { post: null, tags: [] }; return { post: null, tags: [] };
@@ -167,84 +191,80 @@ export const databaseRouter = createTRPCRouter({
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch post by ID", message: "Failed to fetch post by ID"
}); });
} }
}), }),
getPostByTitle: publicProcedure getPostByTitle: publicProcedure
.input(z.object({ .input(
category: z.literal("blog"), z.object({
title: z.string(), category: z.literal("blog"),
})) title: z.string()
})
)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
try { try {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Get post by title // Get post by title with JOINs to get all related data in one query
const postQuery = "SELECT * FROM Post WHERE title = ? AND category = ? AND published = ?"; 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({ const postResults = await conn.execute({
sql: postQuery, sql: postQuery,
args: [input.title, input.category, true], args: [input.title, input.category, true]
}); });
if (!postResults.rows[0]) { if (!postResults.rows[0]) {
return null; return null;
} }
const post_id = (postResults.rows[0] as any).id; const postRow = postResults.rows[0];
// 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],
});
// Return structured data with proper formatting
return { return {
post: postResults.rows[0], post: postRow,
comments: commentResults.rows, comments: [], // Comments are not included in this optimized query - would need separate call if needed
likes: likeResults.rows, likes: [], // Likes are not included in this optimized query - would need separate call if needed
tagResults: tagResults.rows, tags: postRow.tags ? postRow.tags.split(",") : []
}; };
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch post by title", message: "Failed to fetch post by title"
}); });
} }
}), }),
createPost: publicProcedure createPost: publicProcedure
.input(z.object({ .input(
category: z.literal("blog"), z.object({
title: z.string(), category: z.literal("blog"),
subtitle: z.string().nullable(), title: z.string(),
body: z.string().nullable(), subtitle: z.string().nullable(),
banner_photo: z.string().nullable(), body: z.string().nullable(),
published: z.boolean(), banner_photo: z.string().nullable(),
tags: z.array(z.string()).nullable(), published: z.boolean(),
author_id: z.string(), tags: z.array(z.string()).nullable(),
})) author_id: z.string()
})
)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
const fullURL = input.banner_photo const fullURL = input.banner_photo
? env.NEXT_PUBLIC_AWS_BUCKET_STRING + input.banner_photo ? env.NEXT_PUBLIC_AWS_BUCKET_STRING + input.banner_photo
: null; : null;
const query = ` const query = `
@@ -258,11 +278,11 @@ export const databaseRouter = createTRPCRouter({
input.body, input.body,
fullURL, fullURL,
input.published, input.published,
input.author_id, input.author_id
]; ];
const results = await conn.execute({ sql: query, args: params }); const results = await conn.execute({ sql: query, args: params });
if (input.tags && input.tags.length > 0) { if (input.tags && input.tags.length > 0) {
let tagQuery = "INSERT INTO Tag (value, post_id) VALUES "; let tagQuery = "INSERT INTO Tag (value, post_id) VALUES ";
let values = input.tags.map( let values = input.tags.map(
@@ -277,26 +297,28 @@ export const databaseRouter = createTRPCRouter({
console.error(error); console.error(error);
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to create post", message: "Failed to create post"
}); });
} }
}), }),
updatePost: publicProcedure updatePost: publicProcedure
.input(z.object({ .input(
id: z.number(), z.object({
title: z.string().nullable().optional(), id: z.number(),
subtitle: z.string().nullable().optional(), title: z.string().nullable().optional(),
body: z.string().nullable().optional(), subtitle: z.string().nullable().optional(),
banner_photo: z.string().nullable().optional(), body: z.string().nullable().optional(),
published: z.boolean().nullable().optional(), banner_photo: z.string().nullable().optional(),
tags: z.array(z.string()).nullable().optional(), published: z.boolean().nullable().optional(),
author_id: z.string(), tags: z.array(z.string()).nullable().optional(),
})) author_id: z.string()
})
)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
let query = "UPDATE Post SET "; let query = "UPDATE Post SET ";
let params: any[] = []; let params: any[] = [];
let first = true; let first = true;
@@ -345,8 +367,11 @@ export const databaseRouter = createTRPCRouter({
// Handle tags // Handle tags
const deleteTagsQuery = `DELETE FROM Tag WHERE post_id = ?`; 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) { if (input.tags && input.tags.length > 0) {
let tagQuery = "INSERT INTO Tag (value, post_id) VALUES "; let tagQuery = "INSERT INTO Tag (value, post_id) VALUES ";
let values = input.tags.map((tag) => `("${tag}", ${input.id})`); let values = input.tags.map((tag) => `("${tag}", ${input.id})`);
@@ -359,7 +384,7 @@ export const databaseRouter = createTRPCRouter({
console.error(error); console.error(error);
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", 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 }) => { .mutation(async ({ input }) => {
try { try {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Delete associated tags first // Delete associated tags first
await conn.execute({ await conn.execute({
sql: "DELETE FROM Tag WHERE post_id = ?", sql: "DELETE FROM Tag WHERE post_id = ?",
args: [input.id.toString()], args: [input.id.toString()]
}); });
// Delete associated likes // Delete associated likes
await conn.execute({ await conn.execute({
sql: "DELETE FROM PostLike WHERE post_id = ?", sql: "DELETE FROM PostLike WHERE post_id = ?",
args: [input.id.toString()], args: [input.id.toString()]
}); });
// Delete associated comments // Delete associated comments
await conn.execute({ await conn.execute({
sql: "DELETE FROM Comment WHERE post_id = ?", sql: "DELETE FROM Comment WHERE post_id = ?",
args: [input.id], args: [input.id]
}); });
// Finally delete the post // Finally delete the post
await conn.execute({ await conn.execute({
sql: "DELETE FROM Post WHERE id = ?", sql: "DELETE FROM Post WHERE id = ?",
args: [input.id], args: [input.id]
}); });
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error(error); console.error(error);
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to delete post", message: "Failed to delete post"
}); });
} }
}), }),
@@ -409,39 +434,43 @@ export const databaseRouter = createTRPCRouter({
// ============================================================ // ============================================================
addPostLike: publicProcedure addPostLike: publicProcedure
.input(z.object({ .input(
user_id: z.string(), z.object({
post_id: z.string(), user_id: z.string(),
})) post_id: z.string()
})
)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
const query = `INSERT INTO PostLike (user_id, post_id) VALUES (?, ?)`; const query = `INSERT INTO PostLike (user_id, post_id) VALUES (?, ?)`;
await conn.execute({ await conn.execute({
sql: query, 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 followUpQuery = `SELECT * FROM PostLike WHERE post_id = ?`;
const res = await conn.execute({ const res = await conn.execute({
sql: followUpQuery, sql: followUpQuery,
args: [input.post_id], args: [input.post_id]
}); });
return { newLikes: res.rows }; return { newLikes: res.rows };
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to add post like", message: "Failed to add post like"
}); });
} }
}), }),
removePostLike: publicProcedure removePostLike: publicProcedure
.input(z.object({ .input(
user_id: z.string(), z.object({
post_id: z.string(), user_id: z.string(),
})) post_id: z.string()
})
)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
@@ -451,20 +480,20 @@ export const databaseRouter = createTRPCRouter({
`; `;
await conn.execute({ await conn.execute({
sql: query, 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 followUpQuery = `SELECT * FROM PostLike WHERE post_id = ?`;
const res = await conn.execute({ const res = await conn.execute({
sql: followUpQuery, sql: followUpQuery,
args: [input.post_id], args: [input.post_id]
}); });
return { newLikes: res.rows }; return { newLikes: res.rows };
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", 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 query = "SELECT * FROM User WHERE id = ?";
const res = await conn.execute({ const res = await conn.execute({
sql: query, sql: query,
args: [input.id], args: [input.id]
}); });
if (res.rows[0]) { if (res.rows[0]) {
@@ -494,7 +523,7 @@ export const databaseRouter = createTRPCRouter({
image: user.image, image: user.image,
displayName: user.display_name, displayName: user.display_name,
provider: user.provider, provider: user.provider,
hasPassword: !!user.password_hash, hasPassword: !!user.password_hash
}; };
} }
} }
@@ -510,10 +539,11 @@ export const databaseRouter = createTRPCRouter({
.query(async ({ input }) => { .query(async ({ input }) => {
try { try {
const conn = ConnectionFactory(); 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({ const res = await conn.execute({
sql: query, sql: query,
args: [input.id], args: [input.id]
}); });
if (res.rows[0]) { if (res.rows[0]) {
@@ -522,7 +552,7 @@ export const databaseRouter = createTRPCRouter({
return { return {
email: user.email, email: user.email,
image: user.image, 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 query = "SELECT * FROM User WHERE id = ?";
const results = await conn.execute({ const results = await conn.execute({
sql: query, sql: query,
args: [input.id], args: [input.id]
}); });
return { user: results.rows[0] }; return { user: results.rows[0] };
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch user image", message: "Failed to fetch user image"
}); });
} }
}), }),
updateUserImage: publicProcedure updateUserImage: publicProcedure
.input(z.object({ .input(
id: z.string(), z.object({
imageURL: z.string(), id: z.string(),
})) imageURL: z.string()
})
)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
@@ -566,38 +598,40 @@ export const databaseRouter = createTRPCRouter({
const query = `UPDATE User SET image = ? WHERE id = ?`; const query = `UPDATE User SET image = ? WHERE id = ?`;
await conn.execute({ await conn.execute({
sql: query, sql: query,
args: [fullURL, input.id], args: [fullURL, input.id]
}); });
return { res: "success" }; return { res: "success" };
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to update user image", message: "Failed to update user image"
}); });
} }
}), }),
updateUserEmail: publicProcedure updateUserEmail: publicProcedure
.input(z.object({ .input(
id: z.string(), z.object({
newEmail: z.string().email(), id: z.string(),
oldEmail: z.string().email(), newEmail: z.string().email(),
})) oldEmail: z.string().email()
})
)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
const query = `UPDATE User SET email = ? WHERE id = ? AND email = ?`; const query = `UPDATE User SET email = ? WHERE id = ? AND email = ?`;
const res = await conn.execute({ const res = await conn.execute({
sql: query, sql: query,
args: [input.newEmail, input.id, input.oldEmail], args: [input.newEmail, input.id, input.oldEmail]
}); });
return { res }; return { res };
} catch (error) { } catch (error) {
console.error(error); console.error(error);
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to update user email", message: "Failed to update user email"
}); });
} }
}), })
}); });

View File

@@ -11,7 +11,7 @@ export const LINEAGE_JWT_EXPIRY = "14d";
// Helper function to get privilege level from H3Event (for use outside tRPC) // Helper function to get privilege level from H3Event (for use outside tRPC)
export async function getPrivilegeLevel( export async function getPrivilegeLevel(
event: H3Event, event: H3Event
): Promise<"anonymous" | "admin" | "user"> { ): Promise<"anonymous" | "admin" | "user"> {
try { try {
const userIDToken = getCookie(event, "userIDToken"); const userIDToken = getCookie(event, "userIDToken");
@@ -28,7 +28,7 @@ export async function getPrivilegeLevel(
console.log("Failed to authenticate token."); console.log("Failed to authenticate token.");
setCookie(event, "userIDToken", "", { setCookie(event, "userIDToken", "", {
maxAge: 0, 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<string | null> {
console.log("Failed to authenticate token."); console.log("Failed to authenticate token.");
setCookie(event, "userIDToken", "", { setCookie(event, "userIDToken", "", {
maxAge: 0, 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<string | null> {
return null; return null;
} }
// Turso // Turso - Connection Pooling Implementation
export function ConnectionFactory() { let mainDBConnection: ReturnType<typeof createClient> | null = null;
const config = { let lineageDBConnection: ReturnType<typeof createClient> | null = null;
url: env.TURSO_DB_URL,
authToken: env.TURSO_DB_TOKEN,
};
const conn = createClient(config); export function ConnectionFactory() {
return conn; if (!mainDBConnection) {
const config = {
url: env.TURSO_DB_URL,
authToken: env.TURSO_DB_TOKEN
};
mainDBConnection = createClient(config);
}
return mainDBConnection;
} }
export function LineageConnectionFactory() { export function LineageConnectionFactory() {
const config = { if (!lineageDBConnection) {
url: env.TURSO_LINEAGE_URL, const config = {
authToken: env.TURSO_LINEAGE_TOKEN, url: env.TURSO_LINEAGE_URL,
}; authToken: env.TURSO_LINEAGE_TOKEN
};
const conn = createClient(config); lineageDBConnection = createClient(config);
return conn; }
return lineageDBConnection;
} }
export async function LineageDBInit() { export async function LineageDBInit() {
const turso = createAPIClient({ const turso = createAPIClient({
org: "mikefreno", org: "mikefreno",
token: env.TURSO_DB_API_TOKEN, token: env.TURSO_DB_API_TOKEN
}); });
const db_name = uuid(); const db_name = uuid();
const db = await turso.databases.create(db_name, { group: "default" }); const db = await turso.databases.create(db_name, { group: "default" });
const token = await turso.databases.createToken(db_name, { const token = await turso.databases.createToken(db_name, {
authorization: "full-access", authorization: "full-access"
}); });
const conn = PerUserDBConnectionFactory(db.name, token.jwt); const conn = PerUserDBConnectionFactory(db.name, token.jwt);
@@ -121,7 +126,7 @@ export async function LineageDBInit() {
export function PerUserDBConnectionFactory(dbName: string, token: string) { export function PerUserDBConnectionFactory(dbName: string, token: string) {
const config = { const config = {
url: `libsql://${dbName}-mikefreno.turso.io`, url: `libsql://${dbName}-mikefreno.turso.io`,
authToken: token, authToken: token
}; };
const conn = createClient(config); const conn = createClient(config);
return conn; return conn;
@@ -130,7 +135,7 @@ export function PerUserDBConnectionFactory(dbName: string, token: string) {
export async function dumpAndSendDB({ export async function dumpAndSendDB({
dbName, dbName,
dbToken, dbToken,
sendTarget, sendTarget
}: { }: {
dbName: string; dbName: string;
dbToken: string; dbToken: string;
@@ -142,8 +147,8 @@ export async function dumpAndSendDB({
const res = await fetch(`https://${dbName}-mikefreno.turso.io/dump`, { const res = await fetch(`https://${dbName}-mikefreno.turso.io/dump`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${dbToken}`, Authorization: `Bearer ${dbToken}`
}, }
}); });
if (!res.ok) { if (!res.ok) {
console.error(res); console.error(res);
@@ -158,12 +163,12 @@ export async function dumpAndSendDB({
const emailPayload = { const emailPayload = {
sender: { sender: {
name: "no_reply@freno.me", name: "no_reply@freno.me",
email: "no_reply@freno.me", email: "no_reply@freno.me"
}, },
to: [ to: [
{ {
email: sendTarget, email: sendTarget
}, }
], ],
subject: "Your Lineage Database Dump", subject: "Your Lineage Database Dump",
htmlContent: htmlContent:
@@ -171,18 +176,18 @@ export async function dumpAndSendDB({
attachment: [ attachment: [
{ {
content: base64Content, content: base64Content,
name: "database_dump.txt", name: "database_dump.txt"
}, }
], ]
}; };
const sendRes = await fetch(apiUrl, { const sendRes = await fetch(apiUrl, {
method: "POST", method: "POST",
headers: { headers: {
accept: "application/json", accept: "application/json",
"api-key": apiKey, "api-key": apiKey,
"content-type": "application/json", "content-type": "application/json"
}, },
body: JSON.stringify(emailPayload), body: JSON.stringify(emailPayload)
}); });
if (!sendRes.ok) { if (!sendRes.ok) {
@@ -194,7 +199,7 @@ export async function dumpAndSendDB({
export async function validateLineageRequest({ export async function validateLineageRequest({
auth_token, auth_token,
userRow, userRow
}: { }: {
auth_token: string; auth_token: string;
userRow: Row; userRow: Row;
@@ -225,7 +230,7 @@ export async function validateLineageRequest({
const client = new OAuth2Client(CLIENT_ID); const client = new OAuth2Client(CLIENT_ID);
const ticket = await client.verifyIdToken({ const ticket = await client.verifyIdToken({
idToken: auth_token, idToken: auth_token,
audience: CLIENT_ID, audience: CLIENT_ID
}); });
if (ticket.getPayload()?.email !== email) { if (ticket.getPayload()?.email !== email) {
return false; return false;
@@ -267,17 +272,18 @@ export async function sendEmailVerification(userEmail: string): Promise<{
.setExpirationTime("15m") .setExpirationTime("15m")
.sign(secret); .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 = { const emailPayload = {
sender: { sender: {
name: "MikeFreno", name: "MikeFreno",
email: "lifeandlineage_no_reply@freno.me", email: "lifeandlineage_no_reply@freno.me"
}, },
to: [ to: [
{ {
email: userEmail, email: userEmail
}, }
], ],
htmlContent: `<html> htmlContent: `<html>
<head> <head>
@@ -314,7 +320,7 @@ export async function sendEmailVerification(userEmail: string): Promise<{
</body> </body>
</html> </html>
`, `,
subject: `Life and Lineage email verification`, subject: `Life and Lineage email verification`
}; };
try { try {
@@ -323,16 +329,16 @@ export async function sendEmailVerification(userEmail: string): Promise<{
headers: { headers: {
accept: "application/json", accept: "application/json",
"api-key": apiKey, "api-key": apiKey,
"content-type": "application/json", "content-type": "application/json"
}, },
body: JSON.stringify(emailPayload), body: JSON.stringify(emailPayload)
}); });
if (!res.ok) { if (!res.ok) {
return { success: false, message: "Failed to send email" }; 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) { if (json.messageId) {
return { success: true, messageId: json.messageId }; return { success: true, messageId: json.messageId };
} }