oh baby boy

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

View File

@@ -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<string, number> = {};
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();

View File

@@ -0,0 +1,312 @@
import { createTRPCRouter, publicProcedure } from "../utils";
import { ConnectionFactory } from "~/server/utils";
import { z } from "zod";
import { getUserID } from "~/server/auth";
import { TRPCError } from "@trpc/server";
import diff from "fast-diff";
// Helper to create diff patch between two HTML strings
export function createDiffPatch(
oldContent: string,
newContent: string
): string {
const changes = diff(oldContent, newContent);
return JSON.stringify(changes);
}
// Helper to apply diff patch to content
export function applyDiffPatch(baseContent: string, patchJson: string): string {
const changes = JSON.parse(patchJson);
let result = "";
let position = 0;
for (const [operation, text] of changes) {
if (operation === diff.EQUAL) {
result += text;
position += text.length;
} else if (operation === diff.DELETE) {
position += text.length;
} else if (operation === diff.INSERT) {
result += text;
}
}
return result;
}
// Helper to reconstruct content from history chain
async function reconstructContent(
conn: ReturnType<typeof ConnectionFactory>,
historyId: number
): Promise<string> {
// Get the full chain from root to this history entry
const chain: Array<{
id: number;
parent_id: number | null;
content: string;
}> = [];
let currentId: number | null = historyId;
while (currentId !== null) {
const result = await conn.execute({
sql: "SELECT id, parent_id, content FROM PostHistory WHERE id = ?",
args: [currentId]
});
if (result.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "History entry not found"
});
}
const row = result.rows[0] as {
id: number;
parent_id: number | null;
content: string;
};
chain.unshift(row);
currentId = row.parent_id;
}
// Apply patches in order
let content = "";
for (const entry of chain) {
content = applyDiffPatch(content, entry.content);
}
return content;
}
export const postHistoryRouter = createTRPCRouter({
// Save a new history entry
save: publicProcedure
.input(
z.object({
postId: z.number(),
content: z.string(),
previousContent: z.string(),
parentHistoryId: z.number().nullable(),
isSaved: z.boolean().default(false)
})
)
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Must be authenticated to save history"
});
}
const conn = ConnectionFactory();
// Verify post exists and user is author
const postCheck = await conn.execute({
sql: "SELECT author_id FROM Post WHERE id = ?",
args: [input.postId]
});
if (postCheck.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Post not found"
});
}
const post = postCheck.rows[0] as { author_id: string };
if (post.author_id !== userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Not authorized to modify this post"
});
}
// Create diff patch
const diffPatch = createDiffPatch(input.previousContent, input.content);
// Insert history entry
const result = await conn.execute({
sql: `
INSERT INTO PostHistory (post_id, parent_id, content, is_saved)
VALUES (?, ?, ?, ?)
`,
args: [
input.postId,
input.parentHistoryId,
diffPatch,
input.isSaved ? 1 : 0
]
});
// Prune old history entries if we exceed 100
const countResult = await conn.execute({
sql: "SELECT COUNT(*) as count FROM PostHistory WHERE post_id = ?",
args: [input.postId]
});
const count = (countResult.rows[0] as { count: number }).count;
if (count > 100) {
// Get the oldest entries to delete (keep most recent 100)
const toDelete = await conn.execute({
sql: `
SELECT id FROM PostHistory
WHERE post_id = ?
ORDER BY created_at ASC
LIMIT ?
`,
args: [input.postId, count - 100]
});
// Delete old entries
for (const row of toDelete.rows) {
const entry = row as { id: number };
await conn.execute({
sql: "DELETE FROM PostHistory WHERE id = ?",
args: [entry.id]
});
}
}
return {
success: true,
historyId: Number(result.lastInsertRowid)
};
}),
// Get history for a post with reconstructed content
getHistory: publicProcedure
.input(z.object({ postId: z.number() }))
.query(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Must be authenticated to view history"
});
}
const conn = ConnectionFactory();
// Verify post exists and user is author
const postCheck = await conn.execute({
sql: "SELECT author_id FROM Post WHERE id = ?",
args: [input.postId]
});
if (postCheck.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Post not found"
});
}
const post = postCheck.rows[0] as { author_id: string };
if (post.author_id !== userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Not authorized to view this post's history"
});
}
// Get all history entries for this post
const result = await conn.execute({
sql: `
SELECT id, parent_id, content, created_at, is_saved
FROM PostHistory
WHERE post_id = ?
ORDER BY created_at ASC
`,
args: [input.postId]
});
const entries = result.rows as Array<{
id: number;
parent_id: number | null;
content: string;
created_at: string;
is_saved: number;
}>;
// Reconstruct content for each entry by applying diffs sequentially
const historyWithContent: Array<{
id: number;
parent_id: number | null;
content: string;
created_at: string;
is_saved: number;
}> = [];
let accumulatedContent = "";
for (const entry of entries) {
accumulatedContent = applyDiffPatch(accumulatedContent, entry.content);
historyWithContent.push({
id: entry.id,
parent_id: entry.parent_id,
content: accumulatedContent,
created_at: entry.created_at,
is_saved: entry.is_saved
});
}
return historyWithContent;
}),
// Restore content from a history entry
restore: publicProcedure
.input(z.object({ historyId: z.number() }))
.query(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Must be authenticated to restore history"
});
}
const conn = ConnectionFactory();
// Get history entry and verify ownership
const historyResult = await conn.execute({
sql: `
SELECT ph.post_id
FROM PostHistory ph
JOIN Post p ON ph.post_id = p.id
WHERE ph.id = ?
`,
args: [input.historyId]
});
if (historyResult.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "History entry not found"
});
}
const historyEntry = historyResult.rows[0] as { post_id: number };
// Verify user is post author
const postCheck = await conn.execute({
sql: "SELECT author_id FROM Post WHERE id = ?",
args: [historyEntry.post_id]
});
const post = postCheck.rows[0] as { author_id: string };
if (post.author_id !== userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Not authorized to restore this post's history"
});
}
// Reconstruct content from history chain
const content = await reconstructContent(conn, input.historyId);
return { content };
})
});

View File

@@ -1,7 +1,5 @@
import { createTRPCRouter, publicProcedure } from "../utils";
import { 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);