oh baby boy

This commit is contained in:
Michael Freno
2025-12-26 13:41:50 -05:00
parent 4e34e53515
commit 53a4ae1a43
14 changed files with 1617 additions and 54 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -44,6 +44,7 @@
"@vercel/speed-insights": "^1.3.1", "@vercel/speed-insights": "^1.3.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"es-toolkit": "^1.43.0", "es-toolkit": "^1.43.0",
"fast-diff": "^1.3.0",
"google-auth-library": "^10.5.0", "google-auth-library": "^10.5.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"jose": "^6.1.3", "jose": "^6.1.3",
@@ -62,6 +63,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/fast-diff": "^1.2.2",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
"trpc-panel": "^1.3.4" "trpc-panel": "^1.3.4"

View File

@@ -503,7 +503,11 @@ export default function PostForm(props: PostFormProps) {
{/* Text Editor */} {/* Text Editor */}
<div class="w-full max-w-full overflow-hidden"> <div class="w-full max-w-full overflow-hidden">
<TextEditor updateContent={setBody} preSet={initialBody()} /> <TextEditor
updateContent={setBody}
preSet={initialBody()}
postId={props.postId}
/>
</div> </div>
{/* Tags */} {/* Tags */}

View File

@@ -1,5 +1,6 @@
import { Show, untrack, createEffect, on, createSignal, For } from "solid-js"; import { Show, untrack, createEffect, on, createSignal, For } from "solid-js";
import { useSearchParams, useNavigate } from "@solidjs/router"; import { useSearchParams, useNavigate } from "@solidjs/router";
import { api } from "~/lib/api";
import { createTiptapEditor } from "solid-tiptap"; import { createTiptapEditor } from "solid-tiptap";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link"; import Link from "@tiptap/extension-link";
@@ -548,6 +549,7 @@ const ReferenceSectionMarker = Node.create({
export interface TextEditorProps { export interface TextEditorProps {
updateContent: (content: string) => void; updateContent: (content: string) => void;
preSet?: string; preSet?: string;
postId?: number; // Optional: for persisting history to database
} }
export default function TextEditor(props: TextEditorProps) { export default function TextEditor(props: TextEditorProps) {
@@ -624,6 +626,24 @@ export default function TextEditor(props: TextEditorProps) {
const [keyboardVisible, setKeyboardVisible] = createSignal(false); const [keyboardVisible, setKeyboardVisible] = createSignal(false);
const [keyboardHeight, setKeyboardHeight] = createSignal(0); 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<HistoryNode[]>([]);
const [currentHistoryIndex, setCurrentHistoryIndex] =
createSignal<number>(-1);
const [showHistoryModal, setShowHistoryModal] = createSignal(false);
const [isLoadingHistory, setIsLoadingHistory] = createSignal(false);
const MAX_HISTORY_SIZE = 100; // Match database pruning limit
let historyDebounceTimer: ReturnType<typeof setTimeout> | 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 // Force reactive updates for button states
const [editorState, setEditorState] = createSignal(0); const [editorState, setEditorState] = createSignal(0);
@@ -662,6 +682,169 @@ export default function TextEditor(props: TextEditorProps) {
return `${baseClasses} ${activeClass} ${hoverClass}`.trim(); 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(() => ({ const editor = createTiptapEditor(() => ({
element: editorRef, element: editorRef,
extensions: [ extensions: [
@@ -811,6 +994,17 @@ export default function TextEditor(props: TextEditorProps) {
renumberAllReferences(editor); renumberAllReferences(editor);
updateReferencesSection(editor); updateReferencesSection(editor);
}, 100); }, 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 }) => { onSelectionUpdate: ({ editor }) => {
@@ -840,18 +1034,68 @@ export default function TextEditor(props: TextEditorProps) {
createEffect( createEffect(
on( on(
() => props.preSet, () => props.preSet,
(newContent) => { async (newContent) => {
const instance = editor(); const instance = editor();
if (instance && newContent && instance.getHTML() !== newContent) { if (instance && newContent && instance.getHTML() !== newContent) {
console.log("[History] Initial content load, postId:", props.postId);
instance.commands.setContent(newContent, { emitUpdate: false }); 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 // Migrate legacy superscript references to Reference marks
setTimeout(() => migrateLegacyReferences(instance), 50); 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 } { 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) => { const migrateLegacyReferences = (editorInstance: any) => {
if (!editorInstance) return; if (!editorInstance) return;
@@ -1278,20 +1522,63 @@ export default function TextEditor(props: TextEditorProps) {
hasChanges = true; hasChanges = true;
}); });
// Step 2: Add placeholders for new references // Step 2: Add placeholders for new references in correct order
if (referencesHeadingPos >= 0) { if (referencesHeadingPos >= 0) {
// Find insertion point (after heading, before any content or at section end) // For each missing reference, find the correct insertion position
let insertPos = referencesHeadingPos;
const headingNode = doc.nodeAt(referencesHeadingPos);
if (headingNode) {
insertPos = referencesHeadingPos + headingNode.nodeSize;
}
// Add missing references in order
const nodesToInsert: any[] = [];
refNumbers.forEach((refNum) => { refNumbers.forEach((refNum) => {
if (!existingRefs.has(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", type: "paragraph",
content: [ content: [
{ {
@@ -1304,18 +1591,17 @@ export default function TextEditor(props: TextEditorProps) {
text: "Add your reference text here" text: "Add your reference text here"
} }
] ]
}); };
}
});
if (nodesToInsert.length > 0) {
nodesToInsert.forEach((nodeData) => {
const node = editorInstance.schema.nodeFromJSON(nodeData); const node = editorInstance.schema.nodeFromJSON(nodeData);
tr.insert(insertPos, node); tr.insert(insertPos, node);
insertPos += node.nodeSize;
}); // Update existingRefs map so subsequent inserts know about this one
hasChanges = true; existingRefs.set(refNum, { pos: insertPos, isPlaceholder: true });
}
hasChanges = true;
}
});
} }
if (hasChanges) { if (hasChanges) {
@@ -1962,7 +2248,7 @@ export default function TextEditor(props: TextEditorProps) {
const toggleFullscreen = () => { const toggleFullscreen = () => {
const newFullscreenState = !isFullscreen(); const newFullscreenState = !isFullscreen();
setIsFullscreen(newFullscreenState); setIsFullscreen(newFullscreenState);
// Update URL search param to persist state // Update URL search param to persist state
setSearchParams({ fullscreen: newFullscreenState ? "true" : undefined }); setSearchParams({ fullscreen: newFullscreenState ? "true" : undefined });
}; };
@@ -2713,6 +2999,14 @@ export default function TextEditor(props: TextEditorProps) {
> >
📑 📑
</button> </button>
<button
type="button"
onClick={() => setShowHistoryModal(true)}
class="hover:bg-surface1 touch-manipulation rounded px-2 py-1 text-xs select-none"
title={`View Document History (${history().length} snapshots)`}
>
🕐
</button>
<div class="border-surface2 mx-1 border-l"></div> <div class="border-surface2 mx-1 border-l"></div>
<button <button
type="button" type="button"
@@ -3145,6 +3439,84 @@ export default function TextEditor(props: TextEditorProps) {
</div> </div>
</div> </div>
</Show> </Show>
{/* History Modal */}
<Show when={showHistoryModal()}>
<div
class="bg-opacity-50 fixed inset-0 z-150 flex items-center justify-center bg-black"
onClick={() => setShowHistoryModal(false)}
>
<div
class="bg-base border-surface2 max-h-[80dvh] w-full max-w-2xl overflow-y-auto rounded-lg border p-6 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div class="mb-6 flex items-center justify-between">
<h2 class="text-text text-2xl font-bold">Document History</h2>
<button
type="button"
onClick={() => setShowHistoryModal(false)}
class="hover:bg-surface1 text-subtext0 rounded p-2 text-xl"
>
</button>
</div>
{/* History List */}
<Show
when={history().length > 0}
fallback={
<div class="text-subtext0 py-8 text-center">
No history available yet. Start editing to capture history.
</div>
}
>
<div class="space-y-2">
<For each={history()}>
{(node, index) => {
const isCurrent = index() === currentHistoryIndex();
return (
<div
class={`hover:bg-surface1 flex cursor-pointer items-center justify-between rounded-lg border p-3 transition-colors ${
isCurrent
? "border-blue bg-surface1"
: "border-surface2"
}`}
onClick={() => restoreHistory(index())}
>
<div class="flex items-center gap-3">
<span
class={`font-mono text-sm ${
isCurrent
? "text-blue font-bold"
: "text-subtext0"
}`}
>
{isCurrent ? `>${index() + 1}<` : index() + 1}
</span>
<span class="text-text text-sm">
{formatRelativeTime(node.timestamp)}
</span>
</div>
<Show when={isCurrent}>
<span class="text-blue text-xs font-semibold">
CURRENT
</span>
</Show>
</div>
);
}}
</For>
</div>
</Show>
{/* Footer */}
<div class="text-subtext0 border-surface2 mt-6 border-t pt-4 text-center text-sm">
Click on any history item to restore that version
</div>
</div>
</div>
</Show>
</div> </div>
); );
} }

99
src/db/create.ts Normal file
View File

@@ -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);
`
};

96
src/db/types.ts Normal file
View File

@@ -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[];
}

View File

@@ -6,6 +6,7 @@ import { miscRouter } from "./routers/misc";
import { userRouter } from "./routers/user"; import { userRouter } from "./routers/user";
import { blogRouter } from "./routers/blog"; import { blogRouter } from "./routers/blog";
import { gitActivityRouter } from "./routers/git-activity"; import { gitActivityRouter } from "./routers/git-activity";
import { postHistoryRouter } from "./routers/post-history";
import { createTRPCRouter } from "./utils"; import { createTRPCRouter } from "./utils";
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
@@ -16,7 +17,8 @@ export const appRouter = createTRPCRouter({
misc: miscRouter, misc: miscRouter,
user: userRouter, user: userRouter,
blog: blogRouter, blog: blogRouter,
gitActivity: gitActivityRouter gitActivity: gitActivityRouter,
postHistory: postHistoryRouter
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View File

@@ -1,7 +1,8 @@
import { createTRPCRouter, publicProcedure } from "../utils"; import { createTRPCRouter, publicProcedure } from "../utils";
import { ConnectionFactory } from "~/server/utils"; import { ConnectionFactory } from "~/server/utils";
import { withCacheAndStale } from "~/server/cache"; 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 const BLOG_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
@@ -35,7 +36,7 @@ export const blogRouter = createTRPCRouter({
`; `;
const results = await conn.execute(query); 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;`; postsQuery += ` ORDER BY p.date ASC;`;
const postsResult = await conn.execute(postsQuery); const postsResult = await conn.execute(postsQuery);
const posts = postsResult.rows; const posts = postsResult.rows as unknown as PostWithCommentsAndLikes[];
const tagsQuery = ` const tagsQuery = `
SELECT t.value, t.post_id SELECT t.value, t.post_id
@@ -88,10 +89,13 @@ export const blogRouter = createTRPCRouter({
`; `;
const tagsResult = await conn.execute(tagsQuery); 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<string, number> = {}; const tagMap: Record<string, number> = {};
tags.forEach((tag: any) => { tags.forEach((tag) => {
const key = `${tag.value}`; const key = `${tag.value}`;
tagMap[key] = (tagMap[key] || 0) + 1; tagMap[key] = (tagMap[key] || 0) + 1;
}); });
@@ -102,7 +106,7 @@ export const blogRouter = createTRPCRouter({
}), }),
incrementPostRead: publicProcedure incrementPostRead: publicProcedure
.input(z.object({ postId: z.number() })) .input(incrementPostReadSchema)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const conn = ConnectionFactory(); const conn = ConnectionFactory();

View File

@@ -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<typeof ConnectionFactory>,
historyId: number
): Promise<string> {
// 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 };
})
});

View File

@@ -1,7 +1,5 @@
import { createTRPCRouter, publicProcedure } from "../utils"; import { createTRPCRouter, publicProcedure } from "../utils";
import { z } from "zod";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { env } from "~/env/server";
import { import {
ConnectionFactory, ConnectionFactory,
getUserID, getUserID,
@@ -9,8 +7,16 @@ import {
checkPassword checkPassword
} from "~/server/utils"; } from "~/server/utils";
import { setCookie } from "vinxi/http"; import { setCookie } from "vinxi/http";
import type { User } from "~/types/user"; import type { User } from "~/db/types";
import { toUserProfile } from "~/types/user"; import { toUserProfile } from "~/types/user";
import {
updateEmailSchema,
updateDisplayNameSchema,
updateProfileImageSchema,
changePasswordSchema,
setPasswordSchema,
deleteAccountSchema
} from "../schemas/user";
export const userRouter = createTRPCRouter({ export const userRouter = createTRPCRouter({
getProfile: publicProcedure.query(async ({ ctx }) => { getProfile: publicProcedure.query(async ({ ctx }) => {
@@ -41,7 +47,7 @@ export const userRouter = createTRPCRouter({
}), }),
updateEmail: publicProcedure updateEmail: publicProcedure
.input(z.object({ email: z.string().email() })) .input(updateEmailSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent); const userId = await getUserID(ctx.event.nativeEvent);
@@ -75,7 +81,7 @@ export const userRouter = createTRPCRouter({
}), }),
updateDisplayName: publicProcedure updateDisplayName: publicProcedure
.input(z.object({ displayName: z.string().min(1).max(50) })) .input(updateDisplayNameSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent); const userId = await getUserID(ctx.event.nativeEvent);
@@ -104,7 +110,7 @@ export const userRouter = createTRPCRouter({
}), }),
updateProfileImage: publicProcedure updateProfileImage: publicProcedure
.input(z.object({ imageUrl: z.string() })) .input(updateProfileImageSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent); const userId = await getUserID(ctx.event.nativeEvent);
@@ -133,13 +139,7 @@ export const userRouter = createTRPCRouter({
}), }),
changePassword: publicProcedure changePassword: publicProcedure
.input( .input(changePasswordSchema)
z.object({
oldPassword: z.string(),
newPassword: z.string().min(8),
newPasswordConfirmation: z.string().min(8)
})
)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent); const userId = await getUserID(ctx.event.nativeEvent);
@@ -152,6 +152,7 @@ export const userRouter = createTRPCRouter({
const { oldPassword, newPassword, newPasswordConfirmation } = input; const { oldPassword, newPassword, newPasswordConfirmation } = input;
// Schema already validates password match, but double check
if (newPassword !== newPasswordConfirmation) { if (newPassword !== newPasswordConfirmation) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -212,12 +213,7 @@ export const userRouter = createTRPCRouter({
}), }),
setPassword: publicProcedure setPassword: publicProcedure
.input( .input(setPasswordSchema)
z.object({
newPassword: z.string().min(8),
newPasswordConfirmation: z.string().min(8)
})
)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent); const userId = await getUserID(ctx.event.nativeEvent);
@@ -230,6 +226,7 @@ export const userRouter = createTRPCRouter({
const { newPassword, newPasswordConfirmation } = input; const { newPassword, newPasswordConfirmation } = input;
// Schema already validates password match, but double check
if (newPassword !== newPasswordConfirmation) { if (newPassword !== newPasswordConfirmation) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -278,7 +275,7 @@ export const userRouter = createTRPCRouter({
}), }),
deleteAccount: publicProcedure deleteAccount: publicProcedure
.input(z.object({ password: z.string() })) .input(deleteAccountSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent); const userId = await getUserID(ctx.event.nativeEvent);

View File

@@ -1,11 +1,67 @@
import { z } from "zod"; 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 * Post sort mode enum
* Defines available sorting options for blog posts * 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<typeof postCategorySchema>;
export type CreatePostInput = z.infer<typeof createPostSchema>;
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
export type DeletePostInput = z.infer<typeof deletePostSchema>;
export type PostSortMode = z.infer<typeof postSortModeSchema>; export type PostSortMode = z.infer<typeof postSortModeSchema>;
export type PostQueryInput = z.infer<typeof postQueryInputSchema>; export type PostQueryInput = z.infer<typeof postQueryInputSchema>;
export type GetPostInput = z.infer<typeof getPostSchema>;
export type IncrementPostReadInput = z.infer<typeof incrementPostReadSchema>;
export type TogglePostLikeInput = z.infer<typeof togglePostLikeSchema>;
export type AddTagsToPostInput = z.infer<typeof addTagsToPostSchema>;
export type RemoveTagFromPostInput = z.infer<typeof removeTagFromPostSchema>;
export type UpdatePostTagsInput = z.infer<typeof updatePostTagsSchema>;

View File

@@ -2,11 +2,91 @@
* Comment API Validation Schemas * Comment API Validation Schemas
* *
* Zod schemas for comment-related tRPC procedures: * Zod schemas for comment-related tRPC procedures:
* - Comment sorting validation * - Comment creation, updating, deletion
* - Comment reactions
* - Comment sorting and filtering
*/ */
import { z } from "zod"; 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 // Comment Sorting
// ============================================================================ // ============================================================================
@@ -18,4 +98,19 @@ export const commentSortSchema = z
.enum(["newest", "oldest", "highest_rated", "hot"]) .enum(["newest", "oldest", "highest_rated", "hot"])
.default("newest"); .default("newest");
// ============================================================================
// Type Exports
// ============================================================================
export type CommentSortMode = z.infer<typeof commentSortSchema>; export type CommentSortMode = z.infer<typeof commentSortSchema>;
export type ReactionType = z.infer<typeof reactionTypeSchema>;
export type CreateCommentInput = z.infer<typeof createCommentSchema>;
export type UpdateCommentInput = z.infer<typeof updateCommentSchema>;
export type DeleteCommentInput = z.infer<typeof deleteCommentSchema>;
export type GetCommentsInput = z.infer<typeof getCommentsSchema>;
export type ToggleCommentReactionInput = z.infer<
typeof toggleCommentReactionSchema
>;
export type GetCommentReactionsInput = z.infer<
typeof getCommentReactionsSchema
>;

View File

@@ -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<typeof reactionTypeSchema>;
export type CreatePostInput = z.infer<typeof createPostSchema>;
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
export type CreateCommentInput = z.infer<typeof createCommentSchema>;
export type UpdateCommentInput = z.infer<typeof updateCommentSchema>;
export type CreateCommentReactionInput = z.infer<
typeof createCommentReactionSchema
>;
export type CreatePostLikeInput = z.infer<typeof createPostLikeSchema>;
export type CreateTagInput = z.infer<typeof createTagSchema>;
export type CreateConnectionInput = z.infer<typeof createConnectionSchema>;
export type PaginationInput = z.infer<typeof paginationSchema>;

View File

@@ -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<typeof registerUserSchema>;
export type LoginUserInput = z.infer<typeof loginUserSchema>;
export type OAuthProvider = z.infer<typeof oauthProviderSchema>;
export type UpdateEmailInput = z.infer<typeof updateEmailSchema>;
export type UpdateDisplayNameInput = z.infer<typeof updateDisplayNameSchema>;
export type UpdateProfileImageInput = z.infer<typeof updateProfileImageSchema>;
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
export type SetPasswordInput = z.infer<typeof setPasswordSchema>;
export type RequestPasswordResetInput = z.infer<
typeof requestPasswordResetSchema
>;
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
export type DeleteAccountInput = z.infer<typeof deleteAccountSchema>;
export type VerifyEmailInput = z.infer<typeof verifyEmailSchema>;