Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ jobs:
run: npm install -g pnpm@9.15.4

- name: Install dependencies
run: pnpm install
run: pnpm install --no-frozen-lockfile


- name: Run pnpm check:all
run: pnpm check:all
1 change: 1 addition & 0 deletions apps/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ JWT_SECRET="your-super-secret-jwt-key-at-least-32-characters-long"
GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
GOOGLE_REDIRECT_URI="http://localhost:3000/auth/google/callback"
ANON_SECRET_KEY="min 16 char"
# CORS
CORS_ORIGIN="http://localhost:5173"

Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { posts } from "./schema/post.schema";
import { threads } from "./schema/thread.schema";
import { topics } from "./schema/topic.schema";
import { users } from "./schema/user.schema";
import { votes } from "./schema/votes.schema";

export const DrizzleClient = drizzle(env.DATABASE_URL, {
schema: { users, topics, threads, posts },
schema: { users, topics, threads, posts, votes },
});
24 changes: 24 additions & 0 deletions apps/server/src/db/schema/votes.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { pgTable, uniqueIndex, uuid,timestamp,boolean, } from "drizzle-orm/pg-core";
import { posts } from "./post.schema";
import { users } from "./user.schema";
export const votes = pgTable(
"vote",
{
//composite Key:userId, postId
userId: uuid("user_id")
.references(() => users.id, { onDelete: "restrict" })
.notNull(),

postId: uuid("post_id")
.references(() => posts.id, { onDelete: "cascade" })
.notNull(),

isUpvote: boolean("is_upvote").notNull().default(false),
isDownvote: boolean("is_downvote").notNull().default(false),
deletedAt: timestamp("deleted_at", { mode: "string" }),

},
(table) => ({
pk: uniqueIndex("vote_pk").on(table.userId, table.postId),
})
);
11 changes: 11 additions & 0 deletions apps/server/src/dto/votes.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { z } from "zod";

export const postIdParamsSchema = z.object({
postId: z.string().uuid(),
});
export const votePayloadSchema = z.object({
voteValue: z.union([z.literal(1),z.literal(-1),z.literal(0),
]),
});
export type VotePayloadInput = z.infer<typeof votePayloadSchema>;
export type PostIdParams = z.infer<typeof postIdParamsSchema>;
2 changes: 2 additions & 0 deletions apps/server/src/envSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export const envSchema = z.object({
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),

ANON_SECRET_KEY: z.string().min(16, "A9fK2xP7QwL8Rt3ZmX7pQ2sT9vB4nR6HZ4r!Q8u#P2k@M6yNp7L3vR9Qw2Xy6BfT"),
});

export type Env = z.infer<typeof envSchema>;
Expand Down
66 changes: 66 additions & 0 deletions apps/server/src/routers/anonymousName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { FastifyInstance } from "fastify";
import crypto from "node:crypto";
import { z } from "zod";

export async function anonymousRoutes(fastify: FastifyInstance) {

const bodySchema = z.object({
userId: z.string().uuid(),
threadId: z.string().uuid(),
password: z.string().min(1),
});


const animals = [
"Tiger", "Wolf", "Falcon", "Panther", "Phoenix", "Leopard", "Eagle", "Raven",
"Cobra", "Viper", "Hawk", "Dragon", "Lynx", "Jaguar", "Shark", "Stallion",
"Owl", "Rhino", "Bear", "Panda", "Griffin", "Kraken", "Hydra", "Fox",
"Bison", "Cheetah", "Gorilla", "Turtle", "Scorpion", "Mantis",
];

const adjectives = [
"Shadow", "Silent", "Ghost", "Crimson", "Blue", "Iron", "Night", "Frost",
"Storm", "Electric", "Golden", "Cyber", "Wild", "Atomic", "Nebula", "Lunar",
"Solar", "Thunder", "Noble", "Brave", "Swift", "Hidden", "Obsidian", "Phantom",
"Radiant", "Mystic", "Silver", "Scarlet", "Feral", "Arcane",
];

fastify.post("/anon-name", async (request, reply) => {
const parsed = bodySchema.safeParse(request.body);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think auth checks are needed here.


if (!parsed.success) {
return reply.status(400).send({
success: false,
error: "Invalid userId, threadId, or password",
});
}

const { userId, threadId, password } = parsed.data;

try {
const seed = `${userId}-${threadId}`;

const hash = crypto
.createHmac("sha256", password)
.update(seed)
.digest("hex");

const adjIndex = parseInt(hash.substring(0, 8), 16) % adjectives.length;
const animalIndex = parseInt(hash.substring(8, 16), 16) % animals.length;
const num = parseInt(hash.substring(16, 20), 16) % 100;

const anonName = `${adjectives[adjIndex]}${animals[animalIndex]}${num}`;

return reply.status(200).send({
success: true,
anonName,
});
} catch (err) {
fastify.log.error(err);
return reply.status(500).send({
success: false,
error: "Server error generating anonymous name",
});
}
});
}
55 changes: 55 additions & 0 deletions apps/server/src/routers/votes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { handlePostVote } from "@/service/vote.service";
import { attachUser, authenticateUser } from "./auth";

type VoteParams = {
postId: string;
};

type VoteBody = {
voteValue: 1 | -1 | 0;
};

export async function voteRoutes(fastify: FastifyInstance) {
fastify.post<{
Params: VoteParams;
Body: VoteBody;
}>(
"/posts/:postId/vote",
{
preHandler: [authenticateUser, attachUser],
schema: {
params: z.object({
postId: z.string().uuid(),
}),
body: z.object({
voteValue: z.union([
z.literal(1),
z.literal(-1),
z.literal(0),
]),
}),
},
},
async (request, reply) => {
const { postId } = request.params;
const { voteValue } = request.body;
const userId = request.userId;

if (!userId) {
return reply.code(401).send({ error: "Unauthorized" });
}
const updatedPost = await handlePostVote(
postId,
userId,
voteValue
);

return reply.send({
success: true,
data: updatedPost,
});
}
);
}
79 changes: 79 additions & 0 deletions apps/server/src/service/vote.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { and, eq, isNull, sql } from "drizzle-orm";
import { DrizzleClient as db } from "@/db";
import { votes } from "@/db/schema/votes.schema";
import { posts } from "@/db/schema/post.schema";

export async function handlePostVote(
postId: string,
userId: string,
voteValue: 1 | -1 | 0,
) {
return db.transaction(async (tx) => {
// fetch existing active vote
const [existingVote] = await tx
.select()
.from(votes)
.where(
and(
eq(votes.postId, postId),
eq(votes.userId, userId),
isNull(votes.deletedAt),
),
)
.limit(1);

const oldVoteValue = existingVote
? existingVote.isUpvote? 1:-1: 0;
const delta = voteValue - oldVoteValue;

// remove vote (soft delete)
if (voteValue === 0) {
if (existingVote) {
await tx
.update(votes)
.set({ deletedAt: new Date().toISOString() })
.where(
and(
eq(votes.userId, userId),
eq(votes.postId, postId),
),
);
}
} else {
// insert or update vote
await tx
.insert(votes)
.values({
userId,
postId,
isUpvote: voteValue === 1,
isDownvote: voteValue === -1,
deletedAt: null,
})
.onConflictDoUpdate({
target: [votes.userId, votes.postId],
set: {
isUpvote: voteValue === 1,
isDownvote: voteValue === -1,
deletedAt: sql`NULL`,
},
});
}

// update post vote count
const [updatedPost] = await tx
.update(posts)
.set({
vote: sql`${posts.vote} + ${delta}`,
})
.where(eq(posts.id, postId))
.returning();

if (!updatedPost) {
tx.rollback();
throw new Error("Post not found");
}

return updatedPost;
});
}