oh baby boy
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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
|
||||||
|
refNumbers.forEach((refNum) => {
|
||||||
|
if (!existingRefs.has(refNum)) {
|
||||||
|
const refNumInt = parseInt(refNum);
|
||||||
let insertPos = referencesHeadingPos;
|
let insertPos = referencesHeadingPos;
|
||||||
const headingNode = doc.nodeAt(referencesHeadingPos);
|
const headingNode = doc.nodeAt(referencesHeadingPos);
|
||||||
if (headingNode) {
|
if (headingNode) {
|
||||||
insertPos = referencesHeadingPos + headingNode.nodeSize;
|
insertPos = referencesHeadingPos + headingNode.nodeSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add missing references in order
|
// Find the last existing reference that comes before this one
|
||||||
const nodesToInsert: any[] = [];
|
let foundInsertPos = false;
|
||||||
refNumbers.forEach((refNum) => {
|
existingRefs.forEach((info, existingRefNum) => {
|
||||||
if (!existingRefs.has(refNum)) {
|
const existingRefNumInt = parseInt(existingRefNum);
|
||||||
nodesToInsert.push({
|
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
|
||||||
|
existingRefs.set(refNum, { pos: insertPos, isPlaceholder: true });
|
||||||
|
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
@@ -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
99
src/db/create.ts
Normal 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
96
src/db/types.ts
Normal 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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
312
src/server/api/routers/post-history.ts
Normal file
312
src/server/api/routers/post-history.ts
Normal 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 };
|
||||||
|
})
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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
|
||||||
|
>;
|
||||||
|
|||||||
295
src/server/api/schemas/database.ts
Normal file
295
src/server/api/schemas/database.ts
Normal 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>;
|
||||||
159
src/server/api/schemas/user.ts
Normal file
159
src/server/api/schemas/user.ts
Normal 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>;
|
||||||
Reference in New Issue
Block a user