oh baby boy
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
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 { 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);
|
||||
|
||||
|
||||
@@ -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<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 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
|
||||
*
|
||||
* 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<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