diff --git a/bun.lockb b/bun.lockb index eb96a1b..a84ff9c 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 509ce9c..4efefd4 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@vercel/speed-insights": "^1.3.1", "bcrypt": "^6.0.0", "es-toolkit": "^1.43.0", + "fast-diff": "^1.3.0", "google-auth-library": "^10.5.0", "highlight.js": "^11.11.1", "jose": "^6.1.3", @@ -62,6 +63,7 @@ "devDependencies": { "@tailwindcss/typography": "^0.5.19", "@types/bcrypt": "^6.0.0", + "@types/fast-diff": "^1.2.2", "prettier": "^3.7.4", "prettier-plugin-tailwindcss": "^0.7.2", "trpc-panel": "^1.3.4" diff --git a/src/components/blog/PostForm.tsx b/src/components/blog/PostForm.tsx index 919e53f..d0737e0 100644 --- a/src/components/blog/PostForm.tsx +++ b/src/components/blog/PostForm.tsx @@ -503,7 +503,11 @@ export default function PostForm(props: PostFormProps) { {/* Text Editor */}
- +
{/* 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;