From 53a4ae1a4383dae5af93b5a5c106d336afa6e909 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 26 Dec 2025 13:41:50 -0500 Subject: [PATCH] oh baby boy --- bun.lockb | Bin 408595 -> 409351 bytes package.json | 2 + src/components/blog/PostForm.tsx | 6 +- src/components/blog/TextEditor.tsx | 416 +++++++++++++++++++++++-- src/db/create.ts | 99 ++++++ src/db/types.ts | 96 ++++++ src/server/api/root.ts | 4 +- src/server/api/routers/blog.ts | 16 +- src/server/api/routers/post-history.ts | 312 +++++++++++++++++++ src/server/api/routers/user.ts | 37 +-- src/server/api/schemas/blog.ts | 132 +++++++- src/server/api/schemas/comment.ts | 97 +++++- src/server/api/schemas/database.ts | 295 ++++++++++++++++++ src/server/api/schemas/user.ts | 159 ++++++++++ 14 files changed, 1617 insertions(+), 54 deletions(-) create mode 100644 src/db/create.ts create mode 100644 src/db/types.ts create mode 100644 src/server/api/routers/post-history.ts create mode 100644 src/server/api/schemas/database.ts create mode 100644 src/server/api/schemas/user.ts diff --git a/bun.lockb b/bun.lockb index eb96a1b8329de6175d1f7b546d6e99226b94626f..a84ff9cf8cb168017e61db72072ccb16cf07dd41 100755 GIT binary patch delta 66324 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 X@iI`a(-!XZ$3_c_-Nt%_|Iq#)T9zo4 delta 65366 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 - + {/* Tags */} diff --git a/src/components/blog/TextEditor.tsx b/src/components/blog/TextEditor.tsx index 22914fd..9a280b8 100644 --- a/src/components/blog/TextEditor.tsx +++ b/src/components/blog/TextEditor.tsx @@ -1,5 +1,6 @@ import { Show, untrack, createEffect, on, createSignal, For } from "solid-js"; import { useSearchParams, useNavigate } from "@solidjs/router"; +import { api } from "~/lib/api"; import { createTiptapEditor } from "solid-tiptap"; import StarterKit from "@tiptap/starter-kit"; import Link from "@tiptap/extension-link"; @@ -548,6 +549,7 @@ const ReferenceSectionMarker = Node.create({ export interface TextEditorProps { updateContent: (content: string) => void; preSet?: string; + postId?: number; // Optional: for persisting history to database } export default function TextEditor(props: TextEditorProps) { @@ -624,6 +626,24 @@ export default function TextEditor(props: TextEditorProps) { const [keyboardVisible, setKeyboardVisible] = createSignal(false); const [keyboardHeight, setKeyboardHeight] = createSignal(0); + // Undo Tree History (MVP - In-Memory + Database) + interface HistoryNode { + id: string; // Local UUID + dbId?: number; // Database ID from PostHistory table + content: string; + timestamp: Date; + } + + const [history, setHistory] = createSignal([]); + const [currentHistoryIndex, setCurrentHistoryIndex] = + createSignal(-1); + const [showHistoryModal, setShowHistoryModal] = createSignal(false); + const [isLoadingHistory, setIsLoadingHistory] = createSignal(false); + const MAX_HISTORY_SIZE = 100; // Match database pruning limit + let historyDebounceTimer: ReturnType | null = null; + let isInitialLoad = true; // Flag to prevent capturing history on initial load + let hasAttemptedHistoryLoad = false; // Flag to prevent repeated load attempts + // Force reactive updates for button states const [editorState, setEditorState] = createSignal(0); @@ -662,6 +682,169 @@ export default function TextEditor(props: TextEditorProps) { return `${baseClasses} ${activeClass} ${hoverClass}`.trim(); }; + // Capture history snapshot + const captureHistory = async (editorInstance: any) => { + // Skip if initial load + if (isInitialLoad) { + return; + } + + const content = editorInstance.getHTML(); + const currentHistory = history(); + const currentIndex = currentHistoryIndex(); + + // Get previous content for diff creation + const previousContent = + currentIndex >= 0 ? currentHistory[currentIndex].content : ""; + + // Skip if content hasn't changed + if (content === previousContent) { + return; + } + + // Create new history node + const newNode: HistoryNode = { + id: crypto.randomUUID(), + content, + timestamp: new Date() + }; + + // If we're not at the end of history, truncate future history (linear history for MVP) + const updatedHistory = + currentIndex === currentHistory.length - 1 + ? [...currentHistory, newNode] + : [...currentHistory.slice(0, currentIndex + 1), newNode]; + + // Limit history size + const limitedHistory = + updatedHistory.length > MAX_HISTORY_SIZE + ? updatedHistory.slice(updatedHistory.length - MAX_HISTORY_SIZE) + : updatedHistory; + + setHistory(limitedHistory); + setCurrentHistoryIndex(limitedHistory.length - 1); + + // Persist to database if postId is provided + if (props.postId) { + try { + const parentHistoryId = + currentIndex >= 0 && currentHistory[currentIndex]?.dbId + ? currentHistory[currentIndex].dbId + : null; + + const result = await api.postHistory.save.mutate({ + postId: props.postId, + content, + previousContent, + parentHistoryId, + isSaved: false + }); + + // Update the node with database ID + if (result.success && result.historyId) { + newNode.dbId = result.historyId; + // Update history with dbId + setHistory((prev) => { + const updated = [...prev]; + updated[updated.length - 1] = newNode; + return updated; + }); + } + } catch (error) { + console.error("Failed to persist history to database:", error); + // Continue anyway - we have in-memory history + } + } + }; + + // Format relative time for history display + const formatRelativeTime = (date: Date): string => { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + + if (diffSec < 60) return `${diffSec} seconds ago`; + if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? "" : "s"} ago`; + if (diffHour < 24) + return `${diffHour} hour${diffHour === 1 ? "" : "s"} ago`; + return `${diffDay} day${diffDay === 1 ? "" : "s"} ago`; + }; + + // Restore history to a specific point + const restoreHistory = (index: number) => { + const instance = editor(); + if (!instance) return; + + const node = history()[index]; + if (!node) return; + + // Set content without triggering history capture + instance.commands.setContent(node.content, { emitUpdate: false }); + + // Update current index + setCurrentHistoryIndex(index); + + // Update parent content + props.updateContent(node.content); + + // Close modal + setShowHistoryModal(false); + + // Force UI update + setEditorState((prev) => prev + 1); + }; + + // Load history from database + const loadHistoryFromDB = async () => { + if (!props.postId) return; + + setIsLoadingHistory(true); + hasAttemptedHistoryLoad = true; // Mark that we've attempted to load + try { + console.log("[History] Loading from DB for postId:", props.postId); + const dbHistory = await api.postHistory.getHistory.query({ + postId: props.postId + }); + + console.log("[History] DB returned entries:", dbHistory.length); + if (dbHistory && dbHistory.length > 0) { + console.log( + "[History] First entry content length:", + dbHistory[0].content.length + ); + console.log( + "[History] Last entry content length:", + dbHistory[dbHistory.length - 1].content.length + ); + + // Convert database history to HistoryNode format with reconstructed content + const historyNodes: HistoryNode[] = dbHistory.map((entry) => ({ + id: `db-${entry.id}`, + dbId: entry.id, + content: entry.content, // Full reconstructed content from diffs + timestamp: new Date(entry.created_at) + })); + + setHistory(historyNodes); + setCurrentHistoryIndex(historyNodes.length - 1); + console.log( + "[History] Loaded", + historyNodes.length, + "entries into memory" + ); + } else { + console.log("[History] No history found in DB"); + } + } catch (error) { + console.error("Failed to load history from database:", error); + } finally { + setIsLoadingHistory(false); + } + }; + const editor = createTiptapEditor(() => ({ element: editorRef, extensions: [ @@ -811,6 +994,17 @@ export default function TextEditor(props: TextEditorProps) { renumberAllReferences(editor); updateReferencesSection(editor); }, 100); + + // Debounced history capture (capture after 2 seconds of inactivity) + // Skip during initial load + if (!isInitialLoad) { + if (historyDebounceTimer) { + clearTimeout(historyDebounceTimer); + } + historyDebounceTimer = setTimeout(() => { + captureHistory(editor); + }, 2000); + } }); }, onSelectionUpdate: ({ editor }) => { @@ -840,18 +1034,68 @@ export default function TextEditor(props: TextEditorProps) { createEffect( on( () => props.preSet, - (newContent) => { + async (newContent) => { const instance = editor(); if (instance && newContent && instance.getHTML() !== newContent) { + console.log("[History] Initial content load, postId:", props.postId); instance.commands.setContent(newContent, { emitUpdate: false }); + + // Reset the load attempt flag when content changes + hasAttemptedHistoryLoad = false; + + // Load history from database if postId is provided + if (props.postId) { + await loadHistoryFromDB(); + console.log( + "[History] After load, history length:", + history().length + ); + } + // Migrate legacy superscript references to Reference marks setTimeout(() => migrateLegacyReferences(instance), 50); + + // Capture initial state in history only if no history was loaded + setTimeout(() => { + if (history().length === 0) { + console.log( + "[History] No history found, capturing initial state" + ); + captureHistory(instance); + } else { + console.log( + "[History] Skipping initial capture, have", + history().length, + "entries" + ); + } + // Mark initial load as complete - now edits will be captured + isInitialLoad = false; + }, 200); } }, { defer: true } ) ); + // Load history when editor is ready (for edit mode) + createEffect(() => { + const instance = editor(); + if ( + instance && + props.postId && + history().length === 0 && + !isLoadingHistory() && + !hasAttemptedHistoryLoad // Only attempt once + ) { + console.log( + "[History] Editor ready, loading history for postId:", + props.postId + ); + loadHistoryFromDB(); + } + }); + const migrateLegacyReferences = (editorInstance: any) => { if (!editorInstance) return; @@ -1278,20 +1522,63 @@ export default function TextEditor(props: TextEditorProps) { hasChanges = true; }); - // Step 2: Add placeholders for new references + // Step 2: Add placeholders for new references in correct order if (referencesHeadingPos >= 0) { - // Find insertion point (after heading, before any content or at section end) - let insertPos = referencesHeadingPos; - const headingNode = doc.nodeAt(referencesHeadingPos); - if (headingNode) { - insertPos = referencesHeadingPos + headingNode.nodeSize; - } - - // Add missing references in order - const nodesToInsert: any[] = []; + // For each missing reference, find the correct insertion position refNumbers.forEach((refNum) => { if (!existingRefs.has(refNum)) { - nodesToInsert.push({ + const refNumInt = parseInt(refNum); + let insertPos = referencesHeadingPos; + const headingNode = doc.nodeAt(referencesHeadingPos); + if (headingNode) { + insertPos = referencesHeadingPos + headingNode.nodeSize; + } + + // Find the last existing reference that comes before this one + let foundInsertPos = false; + existingRefs.forEach((info, existingRefNum) => { + const existingRefNumInt = parseInt(existingRefNum); + if ( + !isNaN(existingRefNumInt) && + !isNaN(refNumInt) && + existingRefNumInt < refNumInt + ) { + // This existing ref comes before the new one, insert after it + const existingNode = doc.nodeAt(info.pos); + if ( + existingNode && + info.pos + existingNode.nodeSize > insertPos + ) { + insertPos = info.pos + existingNode.nodeSize; + foundInsertPos = true; + } + } + }); + + // If no existing reference comes before this one, but there are references after, + // we've already set insertPos to right after heading which is correct + // If this is larger than all existing refs, find the last one + if (!foundInsertPos && existingRefs.size > 0) { + let maxRefNum = -1; + let maxRefPos = insertPos; + existingRefs.forEach((info, existingRefNum) => { + const existingRefNumInt = parseInt(existingRefNum); + if (!isNaN(existingRefNumInt) && existingRefNumInt > maxRefNum) { + maxRefNum = existingRefNumInt; + maxRefPos = info.pos; + } + }); + + if (maxRefNum >= 0 && refNumInt > maxRefNum) { + // This new ref comes after all existing refs + const maxNode = doc.nodeAt(maxRefPos); + if (maxNode) { + insertPos = maxRefPos + maxNode.nodeSize; + } + } + } + + const nodeData = { type: "paragraph", content: [ { @@ -1304,18 +1591,17 @@ export default function TextEditor(props: TextEditorProps) { text: "Add your reference text here" } ] - }); - } - }); + }; - if (nodesToInsert.length > 0) { - nodesToInsert.forEach((nodeData) => { const node = editorInstance.schema.nodeFromJSON(nodeData); tr.insert(insertPos, node); - insertPos += node.nodeSize; - }); - hasChanges = true; - } + + // Update existingRefs map so subsequent inserts know about this one + existingRefs.set(refNum, { pos: insertPos, isPlaceholder: true }); + + hasChanges = true; + } + }); } if (hasChanges) { @@ -1962,7 +2248,7 @@ export default function TextEditor(props: TextEditorProps) { const toggleFullscreen = () => { const newFullscreenState = !isFullscreen(); setIsFullscreen(newFullscreenState); - + // Update URL search param to persist state setSearchParams({ fullscreen: newFullscreenState ? "true" : undefined }); }; @@ -2713,6 +2999,14 @@ export default function TextEditor(props: TextEditorProps) { > 📑 +
+ + + {/* History List */} + 0} + fallback={ +
+ No history available yet. Start editing to capture history. +
+ } + > +
+ + {(node, index) => { + const isCurrent = index() === currentHistoryIndex(); + return ( +
restoreHistory(index())} + > +
+ + {isCurrent ? `>${index() + 1}<` : index() + 1} + + + {formatRelativeTime(node.timestamp)} + +
+ + + CURRENT + + +
+ ); + }} +
+
+
+ + {/* Footer */} +
+ Click on any history item to restore that version +
+ + + ); } diff --git a/src/db/create.ts b/src/db/create.ts new file mode 100644 index 0000000..27b18d6 --- /dev/null +++ b/src/db/create.ts @@ -0,0 +1,99 @@ +export const model: { [key: string]: string } = { + User: ` + CREATE TABLE User + ( + id TEXT NOT NULL PRIMARY KEY, + email TEXT UNIQUE, + email_verified INTEGER DEFAULT 0, + password_hash TEXT, + display_name TEXT, + provider TEXT, + image TEXT, + registered_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `, + Post: ` + CREATE TABLE Post + ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL UNIQUE, + subtitle TEXT, + body TEXT NOT NULL, + banner_photo TEXT, + date TEXT NOT NULL DEFAULT (datetime('now')), + published INTEGER NOT NULL, + category TEXT, + author_id TEXT NOT NULL, + reads INTEGER NOT NULL DEFAULT 0, + attachments TEXT + ); + CREATE INDEX IF NOT EXISTS idx_posts_category ON Post (category); + `, + PostLike: ` + CREATE TABLE PostLike + ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + post_id INTEGER NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_likes_user_post ON PostLike (user_id, post_id); + `, + Comment: ` + CREATE TABLE Comment + ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + body TEXT NOT NULL, + post_id INTEGER, + parent_comment_id INTEGER, + date TEXT NOT NULL DEFAULT (datetime('now')), + edited INTEGER NOT NULL DEFAULT 0, + commenter_id TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_comment_commenter_id ON Comment (commenter_id); + CREATE INDEX IF NOT EXISTS idx_comment_parent_comment_id ON Comment (parent_comment_id); + CREATE INDEX IF NOT EXISTS idx_comment_post_id ON Comment (post_id); + `, + CommentReaction: ` + CREATE TABLE CommentReaction + ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + comment_id INTEGER NOT NULL, + user_id TEXT NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_reaction_user_type_comment ON CommentReaction (user_id, type, comment_id); + `, + Connection: ` + CREATE TABLE Connection + ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + connection_id TEXT NOT NULL, + post_id INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_connection_post_id ON Connection (post_id); + `, + Tag: ` + CREATE TABLE Tag + ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + value TEXT NOT NULL, + post_id INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_tag_post_id ON Tag (post_id); + `, + PostHistory: ` + CREATE TABLE PostHistory + ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL, + parent_id INTEGER, + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + is_saved INTEGER DEFAULT 0, + FOREIGN KEY (post_id) REFERENCES Post(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_history_post_id ON PostHistory (post_id); + CREATE INDEX IF NOT EXISTS idx_history_parent_id ON PostHistory (parent_id); + ` +}; diff --git a/src/db/types.ts b/src/db/types.ts new file mode 100644 index 0000000..bba4961 --- /dev/null +++ b/src/db/types.ts @@ -0,0 +1,96 @@ +export interface User { + id: string; + email?: string | null; + email_verified: number; + password_hash?: string | null; + display_name?: string | null; + provider?: "email" | "google" | "github" | null; + image?: string | null; + apple_user_string?: string | null; + database_name?: string | null; + database_token?: string | null; + database_url?: string | null; + db_destroy_date?: string | null; + created_at: string; + updated_at: string; +} + +export interface Post { + id: number; + category: "blog" | "project"; // this is no longer used + title: string; + subtitle?: string; + body: string; + banner_photo?: string; + date: string; + published: boolean; + author_id: string; + reads: number; + attachments?: string; +} + +export interface PostLike { + id: number; + user_id: string; + post_id: number; +} + +export interface Comment { + id: number; + body: string; + post_id: number; + parent_comment_id?: number; + date: string; + edited: boolean; + commenter_id: string; +} + +export interface CommentReaction { + id: number; + type: string; + comment_id: number; + user_id: string; +} + +export interface Connection { + id: number; + user_id: string; + connection_id: string; + post_id?: number; +} + +export interface Tag { + id: number; + value: string; + post_id: number; +} + +export interface PostWithCommentsAndLikes { + id: number; + category: "blog" | "project"; // this is no longer used + title: string; + subtitle: string; + body: string; + banner_photo: string; + date: string; + published: boolean; + author_id: string; + reads: number; + attachments: string; + total_likes: number; + total_comments: number; +} +export interface PostWithTags { + id: number; + category: "blog" | "project"; // this is no longer used + title: string; + subtitle: string; + body: string; + banner_photo: string; + date: string; + published: boolean; + author_id: string; + reads: number; + attachments: string; + tags: Tag[]; +} diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 0d03a92..070a947 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -6,6 +6,7 @@ import { miscRouter } from "./routers/misc"; import { userRouter } from "./routers/user"; import { blogRouter } from "./routers/blog"; import { gitActivityRouter } from "./routers/git-activity"; +import { postHistoryRouter } from "./routers/post-history"; import { createTRPCRouter } from "./utils"; export const appRouter = createTRPCRouter({ @@ -16,7 +17,8 @@ export const appRouter = createTRPCRouter({ misc: miscRouter, user: userRouter, blog: blogRouter, - gitActivity: gitActivityRouter + gitActivity: gitActivityRouter, + postHistory: postHistoryRouter }); export type AppRouter = typeof appRouter; diff --git a/src/server/api/routers/blog.ts b/src/server/api/routers/blog.ts index 40ab0ad..c1d925b 100644 --- a/src/server/api/routers/blog.ts +++ b/src/server/api/routers/blog.ts @@ -1,7 +1,8 @@ import { createTRPCRouter, publicProcedure } from "../utils"; import { ConnectionFactory } from "~/server/utils"; import { withCacheAndStale } from "~/server/cache"; -import { z } from "zod"; +import { incrementPostReadSchema } from "../schemas/blog"; +import type { Post, PostWithCommentsAndLikes } from "~/db/types"; const BLOG_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours @@ -35,7 +36,7 @@ export const blogRouter = createTRPCRouter({ `; const results = await conn.execute(query); - return results.rows; + return results.rows as unknown as PostWithCommentsAndLikes[]; }); }), @@ -77,7 +78,7 @@ export const blogRouter = createTRPCRouter({ postsQuery += ` ORDER BY p.date ASC;`; const postsResult = await conn.execute(postsQuery); - const posts = postsResult.rows; + const posts = postsResult.rows as unknown as PostWithCommentsAndLikes[]; const tagsQuery = ` SELECT t.value, t.post_id @@ -88,10 +89,13 @@ export const blogRouter = createTRPCRouter({ `; const tagsResult = await conn.execute(tagsQuery); - const tags = tagsResult.rows; + const tags = tagsResult.rows as unknown as { + value: string; + post_id: number; + }[]; const tagMap: Record = {}; - tags.forEach((tag: any) => { + tags.forEach((tag) => { const key = `${tag.value}`; tagMap[key] = (tagMap[key] || 0) + 1; }); @@ -102,7 +106,7 @@ export const blogRouter = createTRPCRouter({ }), incrementPostRead: publicProcedure - .input(z.object({ postId: z.number() })) + .input(incrementPostReadSchema) .mutation(async ({ input }) => { const conn = ConnectionFactory(); diff --git a/src/server/api/routers/post-history.ts b/src/server/api/routers/post-history.ts new file mode 100644 index 0000000..b13985c --- /dev/null +++ b/src/server/api/routers/post-history.ts @@ -0,0 +1,312 @@ +import { createTRPCRouter, publicProcedure } from "../utils"; +import { ConnectionFactory } from "~/server/utils"; +import { z } from "zod"; +import { getUserID } from "~/server/auth"; +import { TRPCError } from "@trpc/server"; +import diff from "fast-diff"; + +// Helper to create diff patch between two HTML strings +export function createDiffPatch( + oldContent: string, + newContent: string +): string { + const changes = diff(oldContent, newContent); + return JSON.stringify(changes); +} + +// Helper to apply diff patch to content +export function applyDiffPatch(baseContent: string, patchJson: string): string { + const changes = JSON.parse(patchJson); + let result = ""; + let position = 0; + + for (const [operation, text] of changes) { + if (operation === diff.EQUAL) { + result += text; + position += text.length; + } else if (operation === diff.DELETE) { + position += text.length; + } else if (operation === diff.INSERT) { + result += text; + } + } + + return result; +} + +// Helper to reconstruct content from history chain +async function reconstructContent( + conn: ReturnType, + historyId: number +): Promise { + // Get the full chain from root to this history entry + const chain: Array<{ + id: number; + parent_id: number | null; + content: string; + }> = []; + + let currentId: number | null = historyId; + + while (currentId !== null) { + const result = await conn.execute({ + sql: "SELECT id, parent_id, content FROM PostHistory WHERE id = ?", + args: [currentId] + }); + + if (result.rows.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "History entry not found" + }); + } + + const row = result.rows[0] as { + id: number; + parent_id: number | null; + content: string; + }; + chain.unshift(row); + currentId = row.parent_id; + } + + // Apply patches in order + let content = ""; + for (const entry of chain) { + content = applyDiffPatch(content, entry.content); + } + + return content; +} + +export const postHistoryRouter = createTRPCRouter({ + // Save a new history entry + save: publicProcedure + .input( + z.object({ + postId: z.number(), + content: z.string(), + previousContent: z.string(), + parentHistoryId: z.number().nullable(), + isSaved: z.boolean().default(false) + }) + ) + .mutation(async ({ input, ctx }) => { + const userId = await getUserID(ctx.event.nativeEvent); + + if (!userId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Must be authenticated to save history" + }); + } + + const conn = ConnectionFactory(); + + // Verify post exists and user is author + const postCheck = await conn.execute({ + sql: "SELECT author_id FROM Post WHERE id = ?", + args: [input.postId] + }); + + if (postCheck.rows.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Post not found" + }); + } + + const post = postCheck.rows[0] as { author_id: string }; + if (post.author_id !== userId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Not authorized to modify this post" + }); + } + + // Create diff patch + const diffPatch = createDiffPatch(input.previousContent, input.content); + + // Insert history entry + const result = await conn.execute({ + sql: ` + INSERT INTO PostHistory (post_id, parent_id, content, is_saved) + VALUES (?, ?, ?, ?) + `, + args: [ + input.postId, + input.parentHistoryId, + diffPatch, + input.isSaved ? 1 : 0 + ] + }); + + // Prune old history entries if we exceed 100 + const countResult = await conn.execute({ + sql: "SELECT COUNT(*) as count FROM PostHistory WHERE post_id = ?", + args: [input.postId] + }); + + const count = (countResult.rows[0] as { count: number }).count; + if (count > 100) { + // Get the oldest entries to delete (keep most recent 100) + const toDelete = await conn.execute({ + sql: ` + SELECT id FROM PostHistory + WHERE post_id = ? + ORDER BY created_at ASC + LIMIT ? + `, + args: [input.postId, count - 100] + }); + + // Delete old entries + for (const row of toDelete.rows) { + const entry = row as { id: number }; + await conn.execute({ + sql: "DELETE FROM PostHistory WHERE id = ?", + args: [entry.id] + }); + } + } + + return { + success: true, + historyId: Number(result.lastInsertRowid) + }; + }), + + // Get history for a post with reconstructed content + getHistory: publicProcedure + .input(z.object({ postId: z.number() })) + .query(async ({ input, ctx }) => { + const userId = await getUserID(ctx.event.nativeEvent); + + if (!userId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Must be authenticated to view history" + }); + } + + const conn = ConnectionFactory(); + + // Verify post exists and user is author + const postCheck = await conn.execute({ + sql: "SELECT author_id FROM Post WHERE id = ?", + args: [input.postId] + }); + + if (postCheck.rows.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Post not found" + }); + } + + const post = postCheck.rows[0] as { author_id: string }; + if (post.author_id !== userId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Not authorized to view this post's history" + }); + } + + // Get all history entries for this post + const result = await conn.execute({ + sql: ` + SELECT id, parent_id, content, created_at, is_saved + FROM PostHistory + WHERE post_id = ? + ORDER BY created_at ASC + `, + args: [input.postId] + }); + + const entries = result.rows as Array<{ + id: number; + parent_id: number | null; + content: string; + created_at: string; + is_saved: number; + }>; + + // Reconstruct content for each entry by applying diffs sequentially + const historyWithContent: Array<{ + id: number; + parent_id: number | null; + content: string; + created_at: string; + is_saved: number; + }> = []; + + let accumulatedContent = ""; + for (const entry of entries) { + accumulatedContent = applyDiffPatch(accumulatedContent, entry.content); + historyWithContent.push({ + id: entry.id, + parent_id: entry.parent_id, + content: accumulatedContent, + created_at: entry.created_at, + is_saved: entry.is_saved + }); + } + + return historyWithContent; + }), + + // Restore content from a history entry + restore: publicProcedure + .input(z.object({ historyId: z.number() })) + .query(async ({ input, ctx }) => { + const userId = await getUserID(ctx.event.nativeEvent); + + if (!userId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Must be authenticated to restore history" + }); + } + + const conn = ConnectionFactory(); + + // Get history entry and verify ownership + const historyResult = await conn.execute({ + sql: ` + SELECT ph.post_id + FROM PostHistory ph + JOIN Post p ON ph.post_id = p.id + WHERE ph.id = ? + `, + args: [input.historyId] + }); + + if (historyResult.rows.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "History entry not found" + }); + } + + const historyEntry = historyResult.rows[0] as { post_id: number }; + + // Verify user is post author + const postCheck = await conn.execute({ + sql: "SELECT author_id FROM Post WHERE id = ?", + args: [historyEntry.post_id] + }); + + const post = postCheck.rows[0] as { author_id: string }; + if (post.author_id !== userId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Not authorized to restore this post's history" + }); + } + + // Reconstruct content from history chain + const content = await reconstructContent(conn, input.historyId); + + return { content }; + }) +}); diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index b341a59..955abbc 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -1,7 +1,5 @@ import { createTRPCRouter, publicProcedure } from "../utils"; -import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { env } from "~/env/server"; import { ConnectionFactory, getUserID, @@ -9,8 +7,16 @@ import { checkPassword } from "~/server/utils"; import { setCookie } from "vinxi/http"; -import type { User } from "~/types/user"; +import type { User } from "~/db/types"; import { toUserProfile } from "~/types/user"; +import { + updateEmailSchema, + updateDisplayNameSchema, + updateProfileImageSchema, + changePasswordSchema, + setPasswordSchema, + deleteAccountSchema +} from "../schemas/user"; export const userRouter = createTRPCRouter({ getProfile: publicProcedure.query(async ({ ctx }) => { @@ -41,7 +47,7 @@ export const userRouter = createTRPCRouter({ }), updateEmail: publicProcedure - .input(z.object({ email: z.string().email() })) + .input(updateEmailSchema) .mutation(async ({ input, ctx }) => { const userId = await getUserID(ctx.event.nativeEvent); @@ -75,7 +81,7 @@ export const userRouter = createTRPCRouter({ }), updateDisplayName: publicProcedure - .input(z.object({ displayName: z.string().min(1).max(50) })) + .input(updateDisplayNameSchema) .mutation(async ({ input, ctx }) => { const userId = await getUserID(ctx.event.nativeEvent); @@ -104,7 +110,7 @@ export const userRouter = createTRPCRouter({ }), updateProfileImage: publicProcedure - .input(z.object({ imageUrl: z.string() })) + .input(updateProfileImageSchema) .mutation(async ({ input, ctx }) => { const userId = await getUserID(ctx.event.nativeEvent); @@ -133,13 +139,7 @@ export const userRouter = createTRPCRouter({ }), changePassword: publicProcedure - .input( - z.object({ - oldPassword: z.string(), - newPassword: z.string().min(8), - newPasswordConfirmation: z.string().min(8) - }) - ) + .input(changePasswordSchema) .mutation(async ({ input, ctx }) => { const userId = await getUserID(ctx.event.nativeEvent); @@ -152,6 +152,7 @@ export const userRouter = createTRPCRouter({ const { oldPassword, newPassword, newPasswordConfirmation } = input; + // Schema already validates password match, but double check if (newPassword !== newPasswordConfirmation) { throw new TRPCError({ code: "BAD_REQUEST", @@ -212,12 +213,7 @@ export const userRouter = createTRPCRouter({ }), setPassword: publicProcedure - .input( - z.object({ - newPassword: z.string().min(8), - newPasswordConfirmation: z.string().min(8) - }) - ) + .input(setPasswordSchema) .mutation(async ({ input, ctx }) => { const userId = await getUserID(ctx.event.nativeEvent); @@ -230,6 +226,7 @@ export const userRouter = createTRPCRouter({ const { newPassword, newPasswordConfirmation } = input; + // Schema already validates password match, but double check if (newPassword !== newPasswordConfirmation) { throw new TRPCError({ code: "BAD_REQUEST", @@ -278,7 +275,7 @@ export const userRouter = createTRPCRouter({ }), deleteAccount: publicProcedure - .input(z.object({ password: z.string() })) + .input(deleteAccountSchema) .mutation(async ({ input, ctx }) => { const userId = await getUserID(ctx.event.nativeEvent); diff --git a/src/server/api/schemas/blog.ts b/src/server/api/schemas/blog.ts index 793f542..b01a8d2 100644 --- a/src/server/api/schemas/blog.ts +++ b/src/server/api/schemas/blog.ts @@ -1,11 +1,67 @@ import { z } from "zod"; /** - * Blog Query Schemas + * Blog/Post API Validation Schemas * - * Schemas for filtering and sorting blog posts server-side + * Schemas for post creation, updating, querying, and interactions */ +// ============================================================================ +// Post Category and Status +// ============================================================================ + +/** + * Post category enum (deprecated but kept for backward compatibility) + */ +export const postCategorySchema = z.enum(["blog", "project"]); + +// ============================================================================ +// Post Creation and Updates +// ============================================================================ + +/** + * Create new post schema + */ +export const createPostSchema = z.object({ + title: z + .string() + .min(1, "Title is required") + .max(200, "Title must be under 200 characters"), + subtitle: z + .string() + .max(300, "Subtitle must be under 300 characters") + .optional(), + body: z.string().min(1, "Post body is required"), + banner_photo: z.string().url("Must be a valid URL").optional(), + published: z.boolean().default(false), + category: postCategorySchema.default("blog"), + attachments: z.string().optional() +}); + +/** + * Update post schema (partial updates) + */ +export const updatePostSchema = z.object({ + postId: z.number(), + title: z.string().min(1).max(200).optional(), + subtitle: z.string().max(300).optional(), + body: z.string().min(1).optional(), + banner_photo: z.string().url().optional(), + published: z.boolean().optional(), + attachments: z.string().optional() +}); + +/** + * Delete post schema + */ +export const deletePostSchema = z.object({ + postId: z.number() +}); + +// ============================================================================ +// Post Queries and Filtering +// ============================================================================ + /** * Post sort mode enum * Defines available sorting options for blog posts @@ -38,7 +94,77 @@ export const postQueryInputSchema = z.object({ }); /** - * Type exports for use in components + * Get single post by ID or slug */ +export const getPostSchema = z + .object({ + postId: z.number().optional(), + slug: z.string().optional() + }) + .refine((data) => data.postId || data.slug, { + message: "Either postId or slug must be provided" + }); + +// ============================================================================ +// Post Interactions +// ============================================================================ + +/** + * Increment post read count + */ +export const incrementPostReadSchema = z.object({ + postId: z.number() +}); + +/** + * Like/unlike post + */ +export const togglePostLikeSchema = z.object({ + postId: z.number() +}); + +// ============================================================================ +// Tag Management +// ============================================================================ + +/** + * Add tags to post + */ +export const addTagsToPostSchema = z.object({ + postId: z.number(), + tags: z + .array(z.string().min(1).max(50)) + .min(1, "At least one tag is required") +}); + +/** + * Remove tag from post + */ +export const removeTagFromPostSchema = z.object({ + tagId: z.number() +}); + +/** + * Update post tags (replaces all tags) + */ +export const updatePostTagsSchema = z.object({ + postId: z.number(), + tags: z.array(z.string().min(1).max(50)) +}); + +// ============================================================================ +// Type Exports +// ============================================================================ + +export type PostCategory = z.infer; +export type CreatePostInput = z.infer; +export type UpdatePostInput = z.infer; +export type DeletePostInput = z.infer; export type PostSortMode = z.infer; export type PostQueryInput = z.infer; +export type GetPostInput = z.infer; +export type IncrementPostReadInput = z.infer; +export type TogglePostLikeInput = z.infer; +export type AddTagsToPostInput = z.infer; +export type RemoveTagFromPostInput = z.infer; +export type UpdatePostTagsInput = z.infer; diff --git a/src/server/api/schemas/comment.ts b/src/server/api/schemas/comment.ts index 6a7324f..0108551 100644 --- a/src/server/api/schemas/comment.ts +++ b/src/server/api/schemas/comment.ts @@ -2,11 +2,91 @@ * Comment API Validation Schemas * * Zod schemas for comment-related tRPC procedures: - * - Comment sorting validation + * - Comment creation, updating, deletion + * - Comment reactions + * - Comment sorting and filtering */ import { z } from "zod"; +// ============================================================================ +// Comment CRUD Operations +// ============================================================================ + +/** + * Create new comment schema + */ +export const createCommentSchema = z.object({ + body: z + .string() + .min(1, "Comment cannot be empty") + .max(5000, "Comment too long"), + post_id: z.number(), + parent_comment_id: z.number().optional() +}); + +/** + * Update comment schema + */ +export const updateCommentSchema = z.object({ + commentId: z.number(), + body: z + .string() + .min(1, "Comment cannot be empty") + .max(5000, "Comment too long") +}); + +/** + * Delete comment schema + */ +export const deleteCommentSchema = z.object({ + commentId: z.number(), + deletionType: z.enum(["user", "admin", "database"]).optional() +}); + +/** + * Get comments for post schema + */ +export const getCommentsSchema = z.object({ + postId: z.number(), + sortBy: z.enum(["newest", "oldest", "highest_rated", "hot"]).default("newest") +}); + +// ============================================================================ +// Comment Reactions +// ============================================================================ + +/** + * Valid reaction types + */ +export const reactionTypeSchema = z.enum([ + "tears", + "blank", + "tongue", + "cry", + "heartEye", + "angry", + "moneyEye", + "sick", + "upsideDown", + "worried" +]); + +/** + * Add/remove reaction to comment + */ +export const toggleCommentReactionSchema = z.object({ + commentId: z.number(), + reactionType: reactionTypeSchema +}); + +/** + * Get reactions for comment + */ +export const getCommentReactionsSchema = z.object({ + commentId: z.number() +}); + // ============================================================================ // Comment Sorting // ============================================================================ @@ -18,4 +98,19 @@ export const commentSortSchema = z .enum(["newest", "oldest", "highest_rated", "hot"]) .default("newest"); +// ============================================================================ +// Type Exports +// ============================================================================ + export type CommentSortMode = z.infer; +export type ReactionType = z.infer; +export type CreateCommentInput = z.infer; +export type UpdateCommentInput = z.infer; +export type DeleteCommentInput = z.infer; +export type GetCommentsInput = z.infer; +export type ToggleCommentReactionInput = z.infer< + typeof toggleCommentReactionSchema +>; +export type GetCommentReactionsInput = z.infer< + typeof getCommentReactionsSchema +>; diff --git a/src/server/api/schemas/database.ts b/src/server/api/schemas/database.ts new file mode 100644 index 0000000..29c72c5 --- /dev/null +++ b/src/server/api/schemas/database.ts @@ -0,0 +1,295 @@ +import { z } from "zod"; + +/** + * Database Entity Validation Schemas + * + * Zod schemas that mirror the TypeScript interfaces in ~/db/types.ts + * Use these schemas for validating database inputs and outputs in tRPC procedures + */ + +// ============================================================================ +// User Schemas +// ============================================================================ + +/** + * Full User schema matching database structure + */ +export const userSchema = z.object({ + id: z.string(), + email: z.string().email().nullable().optional(), + email_verified: z.number(), + password_hash: z.string().nullable().optional(), + display_name: z.string().nullable().optional(), + provider: z.enum(["email", "google", "github"]).nullable().optional(), + image: z.string().url().nullable().optional(), + apple_user_string: z.string().nullable().optional(), + database_name: z.string().nullable().optional(), + database_token: z.string().nullable().optional(), + database_url: z.string().nullable().optional(), + db_destroy_date: z.string().nullable().optional(), + created_at: z.string(), + updated_at: z.string() +}); + +/** + * User creation input (for registration) + */ +export const createUserSchema = z.object({ + email: z.string().email().optional(), + password: z.string().min(8).optional(), + display_name: z.string().min(1).max(50).optional(), + provider: z.enum(["email", "google", "github"]).optional(), + image: z.string().url().optional() +}); + +/** + * User update input (partial updates) + */ +export const updateUserSchema = z.object({ + email: z.string().email().optional(), + display_name: z.string().min(1).max(50).optional(), + image: z.string().url().optional() +}); + +// ============================================================================ +// Post Schemas +// ============================================================================ + +/** + * Full Post schema matching database structure + */ +export const postSchema = z.object({ + id: z.number(), + category: z.enum(["blog", "project"]), + title: z.string(), + subtitle: z.string().optional(), + body: z.string(), + banner_photo: z.string().optional(), + date: z.string(), + published: z.boolean(), + author_id: z.string(), + reads: z.number(), + attachments: z.string().optional() +}); + +/** + * Post creation input + */ +export const createPostSchema = z.object({ + category: z.enum(["blog", "project"]).default("blog"), + title: z.string().min(1).max(200), + subtitle: z.string().max(300).optional(), + body: z.string().min(1), + banner_photo: z.string().url().optional(), + published: z.boolean().default(false), + attachments: z.string().optional() +}); + +/** + * Post update input (partial updates) + */ +export const updatePostSchema = z.object({ + title: z.string().min(1).max(200).optional(), + subtitle: z.string().max(300).optional(), + body: z.string().min(1).optional(), + banner_photo: z.string().url().optional(), + published: z.boolean().optional(), + attachments: z.string().optional() +}); + +/** + * Post with aggregated data + */ +export const postWithCommentsAndLikesSchema = postSchema.extend({ + total_likes: z.number(), + total_comments: z.number() +}); + +// ============================================================================ +// Comment Schemas +// ============================================================================ + +/** + * Full Comment schema matching database structure + */ +export const commentSchema = z.object({ + id: z.number(), + body: z.string(), + post_id: z.number(), + parent_comment_id: z.number().optional(), + date: z.string(), + edited: z.boolean(), + commenter_id: z.string() +}); + +/** + * Comment creation input + */ +export const createCommentSchema = z.object({ + body: z.string().min(1).max(5000), + post_id: z.number(), + parent_comment_id: z.number().optional() +}); + +/** + * Comment update input + */ +export const updateCommentSchema = z.object({ + body: z.string().min(1).max(5000) +}); + +// ============================================================================ +// CommentReaction Schemas +// ============================================================================ + +/** + * Reaction types for comments + */ +export const reactionTypeSchema = z.enum([ + "tears", + "blank", + "tongue", + "cry", + "heartEye", + "angry", + "moneyEye", + "sick", + "upsideDown", + "worried" +]); + +/** + * Full CommentReaction schema matching database structure + */ +export const commentReactionSchema = z.object({ + id: z.number(), + type: reactionTypeSchema, + comment_id: z.number(), + user_id: z.string() +}); + +/** + * Comment reaction creation input + */ +export const createCommentReactionSchema = z.object({ + type: reactionTypeSchema, + comment_id: z.number() +}); + +// ============================================================================ +// PostLike Schemas +// ============================================================================ + +/** + * Full PostLike schema matching database structure + */ +export const postLikeSchema = z.object({ + id: z.number(), + user_id: z.string(), + post_id: z.number() +}); + +/** + * PostLike creation input + */ +export const createPostLikeSchema = z.object({ + post_id: z.number() +}); + +// ============================================================================ +// Tag Schemas +// ============================================================================ + +/** + * Full Tag schema matching database structure + */ +export const tagSchema = z.object({ + id: z.number(), + value: z.string(), + post_id: z.number() +}); + +/** + * Tag creation input + */ +export const createTagSchema = z.object({ + value: z.string().min(1).max(50), + post_id: z.number() +}); + +/** + * PostWithTags schema + */ +export const postWithTagsSchema = postSchema.extend({ + tags: z.array(tagSchema) +}); + +// ============================================================================ +// Connection Schemas +// ============================================================================ + +/** + * Full Connection schema matching database structure + */ +export const connectionSchema = z.object({ + id: z.number(), + user_id: z.string(), + connection_id: z.string(), + post_id: z.number().optional() +}); + +/** + * Connection creation input + */ +export const createConnectionSchema = z.object({ + connection_id: z.string(), + post_id: z.number().optional() +}); + +// ============================================================================ +// Common Query Schemas +// ============================================================================ + +/** + * ID-based query schemas + */ +export const idSchema = z.object({ + id: z.number() +}); + +export const userIdSchema = z.object({ + userId: z.string() +}); + +export const postIdSchema = z.object({ + postId: z.number() +}); + +export const commentIdSchema = z.object({ + commentId: z.number() +}); + +/** + * Pagination schema + */ +export const paginationSchema = z.object({ + limit: z.number().min(1).max(100).default(10), + offset: z.number().min(0).default(0) +}); + +// ============================================================================ +// Type Exports +// ============================================================================ + +export type ReactionType = z.infer; +export type CreatePostInput = z.infer; +export type UpdatePostInput = z.infer; +export type CreateCommentInput = z.infer; +export type UpdateCommentInput = z.infer; +export type CreateCommentReactionInput = z.infer< + typeof createCommentReactionSchema +>; +export type CreatePostLikeInput = z.infer; +export type CreateTagInput = z.infer; +export type CreateConnectionInput = z.infer; +export type PaginationInput = z.infer; diff --git a/src/server/api/schemas/user.ts b/src/server/api/schemas/user.ts new file mode 100644 index 0000000..48fd50a --- /dev/null +++ b/src/server/api/schemas/user.ts @@ -0,0 +1,159 @@ +import { z } from "zod"; + +/** + * User API Validation Schemas + * + * Zod schemas for user-related operations like authentication, + * profile updates, and password management + */ + +// ============================================================================ +// Authentication Schemas +// ============================================================================ + +/** + * User registration schema + */ +export const registerUserSchema = z + .object({ + email: z.string().email(), + password: z.string().min(8, "Password must be at least 8 characters"), + passwordConfirmation: z.string().min(8) + }) + .refine((data) => data.password === data.passwordConfirmation, { + message: "Passwords do not match", + path: ["passwordConfirmation"] + }); + +/** + * User login schema + */ +export const loginUserSchema = z.object({ + email: z.string().email(), + password: z.string().min(1, "Password is required") +}); + +/** + * OAuth provider schema + */ +export const oauthProviderSchema = z.enum(["google", "github"]); + +// ============================================================================ +// Profile Management Schemas +// ============================================================================ + +/** + * Update email schema + */ +export const updateEmailSchema = z.object({ + email: z.string().email() +}); + +/** + * Update display name schema + */ +export const updateDisplayNameSchema = z.object({ + displayName: z.string().min(1).max(50) +}); + +/** + * Update profile image schema + */ +export const updateProfileImageSchema = z.object({ + imageUrl: z.string().url() +}); + +// ============================================================================ +// Password Management Schemas +// ============================================================================ + +/** + * Change password schema (requires old password) + */ +export const changePasswordSchema = z + .object({ + oldPassword: z.string().min(1, "Current password is required"), + newPassword: z + .string() + .min(8, "New password must be at least 8 characters"), + newPasswordConfirmation: z.string().min(8) + }) + .refine((data) => data.newPassword === data.newPasswordConfirmation, { + message: "Passwords do not match", + path: ["newPasswordConfirmation"] + }) + .refine((data) => data.oldPassword !== data.newPassword, { + message: "New password must be different from current password", + path: ["newPassword"] + }); + +/** + * Set password schema (for OAuth users adding password) + */ +export const setPasswordSchema = z + .object({ + newPassword: z.string().min(8, "Password must be at least 8 characters"), + newPasswordConfirmation: z.string().min(8) + }) + .refine((data) => data.newPassword === data.newPasswordConfirmation, { + message: "Passwords do not match", + path: ["newPasswordConfirmation"] + }); + +/** + * Request password reset schema + */ +export const requestPasswordResetSchema = z.object({ + email: z.string().email() +}); + +/** + * Reset password schema (with token) + */ +export const resetPasswordSchema = z + .object({ + token: z.string().min(1), + newPassword: z.string().min(8, "Password must be at least 8 characters"), + newPasswordConfirmation: z.string().min(8) + }) + .refine((data) => data.newPassword === data.newPasswordConfirmation, { + message: "Passwords do not match", + path: ["newPasswordConfirmation"] + }); + +// ============================================================================ +// Account Management Schemas +// ============================================================================ + +/** + * Delete account schema + */ +export const deleteAccountSchema = z.object({ + password: z.string().min(1, "Password is required to delete account") +}); + +/** + * Email verification schema + */ +export const verifyEmailSchema = z.object({ + token: z.string().min(1) +}); + +// ============================================================================ +// Type Exports +// ============================================================================ + +export type RegisterUserInput = z.infer; +export type LoginUserInput = z.infer; +export type OAuthProvider = z.infer; +export type UpdateEmailInput = z.infer; +export type UpdateDisplayNameInput = z.infer; +export type UpdateProfileImageInput = z.infer; +export type ChangePasswordInput = z.infer; +export type SetPasswordInput = z.infer; +export type RequestPasswordResetInput = z.infer< + typeof requestPasswordResetSchema +>; +export type ResetPasswordInput = z.infer; +export type DeleteAccountInput = z.infer; +export type VerifyEmailInput = z.infer;