Skip to content
Merged
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: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Application
PORT=3000

# Documentation URL
DOCS_URL=YOUR_DOCUMENTATION_URL_HERE

# JWT
JWT_SECRET=YOUR_RANDOM_SECRET_HERE

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Sebelum mulai, pastikan sudah install:
File `.env` digunakan untuk mengkonfigurasi aplikasi. Berikut adalah penjelasan untuk setiap variabel yang ada di `.env.example`:

- `PORT`: Port tempat server akan berjalan (contoh: `3000`).
- `DOCS_URL`: URL untuk dokumentasi API (contoh: `/docs`).
- `JWT_SECRET`: Kunci rahasia acak untuk menandatangani token JWT.
- `GOOGLE_CLIENT_ID`: Client ID dari Google Cloud Console untuk otentikasi Google OAuth.
- `GEMINI_API_KEY`: Kunci API untuk layanan Google Gemini yang digunakan oleh AI Coach.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "public"."CommunityPostLike" (
"userId" TEXT NOT NULL,
"postId" TEXT NOT NULL,

CONSTRAINT "CommunityPostLike_pkey" PRIMARY KEY ("userId","postId")
);

-- CreateIndex
CREATE INDEX "CommunityPostLike_postId_idx" ON "public"."CommunityPostLike"("postId");

-- AddForeignKey
ALTER TABLE "public"."CommunityPostLike" ADD CONSTRAINT "CommunityPostLike_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "public"."CommunityPostLike" ADD CONSTRAINT "CommunityPostLike_postId_fkey" FOREIGN KEY ("postId") REFERENCES "public"."CommunityPost"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
Comment on lines +13 to +16
Copy link

Copilot AI Oct 15, 2025

Choose a reason for hiding this comment

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

[nitpick] For a pure join table, ON DELETE CASCADE is typically preferred to avoid manual cleanup when deleting users or posts; RESTRICT will block deletions and can leave orphaned likeCount if deletes are forced elsewhere. Consider using ON DELETE CASCADE on both FKs.

Suggested change
ALTER TABLE "public"."CommunityPostLike" ADD CONSTRAINT "CommunityPostLike_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."CommunityPostLike" ADD CONSTRAINT "CommunityPostLike_postId_fkey" FOREIGN KEY ("postId") REFERENCES "public"."CommunityPost"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "public"."CommunityPostLike" ADD CONSTRAINT "CommunityPostLike_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."CommunityPostLike" ADD CONSTRAINT "CommunityPostLike_postId_fkey" FOREIGN KEY ("postId") REFERENCES "public"."CommunityPost"("id") ON DELETE CASCADE ON UPDATE CASCADE;

Copilot uses AI. Check for mistakes.
12 changes: 12 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ model User {
posts CommunityPost[]
comments CommunityComment[]
profile UserProfile?
postLikes CommunityPostLike[]
}

model Streak {
Expand Down Expand Up @@ -81,10 +82,21 @@ model CommunityPost {
userId String
user User @relation(fields: [userId], references: [id])
comments CommunityComment[]
postLikes CommunityPostLike[]

@@index([userId])
}

model CommunityPostLike {
userId String
postId String
user User @relation(fields: [userId], references: [id])
post CommunityPost @relation(fields: [postId], references: [id])

@@id([userId, postId])
@@index([postId])
}

model CommunityComment {
id String @id @default(uuid())
content String
Expand Down
18 changes: 15 additions & 3 deletions src/api/community/community.controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Request, Response } from 'express';
import {
addLikeToPost,
createComment,
createPost,
findAllPosts,
toggleLikeOnPost,
type PostCategory,
} from './community.service.js';
import { asyncHandler } from '../../handler/async.handler.js';
Expand Down Expand Up @@ -54,11 +54,23 @@ export const createCommentHandler = asyncHandler(async (req: Request, res: Respo
});

export const addLikeHandler = asyncHandler(async (req: Request, res: Response) => {
const userId = req.user?.id;
const { postId } = req.params;

if (!userId) {
return errorResponse(
res,
401,
'Tidak diizinkan',
'ID pengguna tidak ditemukan dalam permintaan'
);
}
if (!postId) {
return errorResponse(res, 400, 'Permintaan Tidak Valid', 'ID postingan diperlukan');
}

const post = await addLikeToPost(postId);
return successResponse(res, 200, 'Postingan berhasil disukai', { likeCount: post.likeCount });
const post = await toggleLikeOnPost(userId, postId);
const message = post.isLiked ? 'Postingan berhasil disukai' : 'Suka pada postingan dibatalkan';

return successResponse(res, 200, message, post);
Copy link

Copilot AI Oct 15, 2025

Choose a reason for hiding this comment

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

This changes the response shape from { likeCount } to { likedCount, isLiked }, which is a breaking change for clients. To maintain backward compatibility, return likeCount with the original key (e.g., { likeCount: post.likedCount, isLiked }) and consider keeping likedCount as an alias only if necessary.

Suggested change
return successResponse(res, 200, message, post);
return successResponse(res, 200, message, {
likeCount: post.likedCount,
isLiked: post.isLiked,
// Optionally include likedCount as an alias for forward compatibility
// likedCount: post.likedCount,
// ...include other post properties if needed
});

Copilot uses AI. Check for mistakes.
});
62 changes: 54 additions & 8 deletions src/api/community/community.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,17 +109,63 @@ export async function createComment(userId: string, postId: string, content: str
});
}

export async function addLikeToPost(postId: string) {
const updatedPost = await prisma.communityPost.update({
export async function toggleLikeOnPost(userId: string, postId: string) {
const existingLike = await prisma.communityPostLike.findUnique({
where: {
id: postId,
},
data: {
likeCount: {
increment: 1,
userId_postId: {
userId,
postId,
},
},
});

return updatedPost;
if (existingLike) {
const updatedPost = await prisma.$transaction([
prisma.communityPostLike.delete({
where: {
userId_postId: {
userId,
postId,
},
},
}),
prisma.communityPost.update({
where: {
id: postId,
},
data: {
likeCount: {
decrement: 1,
},
},
}),
]);
return {
likedCount: updatedPost[1].likeCount,
isLiked: false,
};
} else {
const updatedPost = await prisma.$transaction([
prisma.communityPostLike.create({
data: {
userId,
postId,
},
}),
prisma.communityPost.update({
where: {
id: postId,
},
data: {
likeCount: {
increment: 1,
},
},
}),
]);
return {
likedCount: updatedPost[1].likeCount,
isLiked: true,
};
}
Comment on lines +122 to +170
Copy link

Copilot AI Oct 15, 2025

Choose a reason for hiding this comment

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

There is a race between the existence check and the transaction, which can cause unique-constraint (P2002) or not-found (P2025) errors under concurrent like/unlike requests and return 500 instead of idempotent responses. Wrap each branch in try/catch and handle Prisma errors (treat P2002 as already liked and P2025 as already unliked), or move to an upsert-style flow (e.g., try create and on P2002 return isLiked=true without changing count, and similarly for delete on P2025) so the endpoint is idempotent under concurrency.

Copilot uses AI. Check for mistakes.
}
3 changes: 2 additions & 1 deletion src/api/content/content.routes.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Router } from 'express';
import { getDailyContentHandler } from './content.controller.js';
import { requireAuth } from '../../middleware/auth.middleware.js';

const router = Router();

router.get('/daily', getDailyContentHandler);
router.get('/daily', requireAuth, getDailyContentHandler);

export default router;
2 changes: 1 addition & 1 deletion src/api/users/user.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export const updateUserSettingsSchema = z.object({
body: z
.object({
nickname: z.string().min(3, 'Nama panggilan harus terdiri dari minimal 3 karakter').trim(),
userWhy: z.string().min(10, 'Alasan Anda harus terdiri dari minimal 10 karakter').trim(),
userWhy: z.string().min(3, 'Alasan Anda harus terdiri dari minimal 3 karakter').trim(),
checkinTime: z
.string()
.regex(/^([01]\d|2[0-3]):([0-5]\d)$/, {
Expand Down
3 changes: 2 additions & 1 deletion src/api/welcome/welcome.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Request, Response } from 'express';
import config from '../../config/index.js';

export function getWelcomeHandler(_req: Request, res: Response) {
res.render('web/index', {
documentationUrl: 'https://documenter.getpostman.com/view/38960737/2sB3QJQBZ8',
documentationUrl: config.docsUrl,
});
}
1 change: 1 addition & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ dotenv.config();
const config = {
port: process.env.PORT || 3000,
databaseUrl: process.env.DATABASE_URL || '',
docsUrl: process.env.DOCS_URL || '',
Copy link

Copilot AI Oct 15, 2025

Choose a reason for hiding this comment

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

Defaulting docsUrl to an empty string can render a blank documentation link on the welcome page when DOCS_URL is not set, regressing from the previous working hardcoded URL. Provide a sensible default (e.g., the prior Postman URL) or fallback in the controller: docsUrl: process.env.DOCS_URL || 'https://documenter.getpostman.com/view/38960737/2sB3QJQBZ8'.

Suggested change
docsUrl: process.env.DOCS_URL || '',
docsUrl: process.env.DOCS_URL || 'https://documenter.getpostman.com/view/38960737/2sB3QJQBZ8',

Copilot uses AI. Check for mistakes.
jwt: {
secret: process.env.JWT_SECRET || '',
},
Expand Down
3 changes: 2 additions & 1 deletion src/middleware/validate.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export const validate =
if (error instanceof ZodError) {
const errorDetails = error.issues.reduce(
(acc, curr) => {
const path = curr.path.join('.') || 'unknown';
let path = curr.path.join('.') || 'unknown';
path = path.replace(/^(body|query|params)\./, '');
acc[path] = curr.message;
return acc;
},
Expand Down
Loading