From 76fd7de5bccde3c401acfc59a6741397a10639a4 Mon Sep 17 00:00:00 2001 From: boytur Date: Wed, 19 Feb 2025 11:26:23 +0700 Subject: [PATCH 01/13] =?UTF-8?q?=F0=9F=94=A7=20chore[Database]:=20create?= =?UTF-8?q?=20User,=20Event,=20Poll,=20Option,=20WhitelistUser,=20Guest,?= =?UTF-8?q?=20VoteRestriction,=20and=20Vote=20tables=20with=20relationship?= =?UTF-8?q?s=20and=20constraints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{20250217164731_ => 20250219021609_}/migration.sql | 4 ---- backend/prisma/schema.prisma | 3 --- 2 files changed, 7 deletions(-) rename backend/prisma/migrations/{20250217164731_ => 20250219021609_}/migration.sql (97%) diff --git a/backend/prisma/migrations/20250217164731_/migration.sql b/backend/prisma/migrations/20250219021609_/migration.sql similarity index 97% rename from backend/prisma/migrations/20250217164731_/migration.sql rename to backend/prisma/migrations/20250219021609_/migration.sql index a9c0419..df1fc03 100644 --- a/backend/prisma/migrations/20250217164731_/migration.sql +++ b/backend/prisma/migrations/20250219021609_/migration.sql @@ -85,7 +85,6 @@ CREATE TABLE "Guest" ( "name" TEXT NOT NULL, "key" TEXT NOT NULL, "eventId" TEXT, - "pollId" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, "deletedAt" TIMESTAMP(3), @@ -154,9 +153,6 @@ ALTER TABLE "WhitelistUser" ADD CONSTRAINT "WhitelistUser_pollId_fkey" FOREIGN K -- AddForeignKey ALTER TABLE "Guest" ADD CONSTRAINT "Guest_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; --- AddForeignKey -ALTER TABLE "Guest" ADD CONSTRAINT "Guest_pollId_fkey" FOREIGN KEY ("pollId") REFERENCES "Poll"("id") ON DELETE CASCADE ON UPDATE CASCADE; - -- AddForeignKey ALTER TABLE "VoteRestriction" ADD CONSTRAINT "VoteRestriction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b8acb45..971b751 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -62,7 +62,6 @@ model Poll { options Option[] whitelist WhitelistUser[] votes Vote[] - guestVotes Guest[] voteRestrict VoteRestriction[] createdAt DateTime @default(now()) @@ -112,8 +111,6 @@ model Guest { key String eventId String? event Event? @relation(fields: [eventId], references: [id], onDelete: Cascade) - pollId String? - poll Poll? @relation(fields: [pollId], references: [id], onDelete: Cascade) votes Vote[] guestVotes VoteRestriction[] From d266effd14c5599b22315a9a1179718881cad0dc Mon Sep 17 00:00:00 2001 From: boytur Date: Wed, 19 Feb 2025 11:26:34 +0700 Subject: [PATCH 02/13] =?UTF-8?q?=E2=9C=A8=20feat[PollController]:=20add?= =?UTF-8?q?=20methods=20to=20fetch=20user's=20polls,=20voted=20polls,=20an?= =?UTF-8?q?d=20public=20polls=20with=20appropriate=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/controllers/poll.controller.ts | 128 +++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/backend/src/controllers/poll.controller.ts b/backend/src/controllers/poll.controller.ts index a219772..6097200 100644 --- a/backend/src/controllers/poll.controller.ts +++ b/backend/src/controllers/poll.controller.ts @@ -9,6 +9,9 @@ export class PollController { constructor(private pollService: PollService) { this.getPoll = this.getPoll.bind(this); this.getPolls = this.getPolls.bind(this); + this.myPolls = this.myPolls.bind(this); + this.publicPolls = this.publicPolls.bind(this); + this.myVotedPolls = this.myVotedPolls.bind(this); } /** @@ -55,6 +58,110 @@ export class PollController { } } + + /** + * Get all polls user has participated in + * @param req - Request + * @param res - Response + * @param next - NextFunction + * @returns - JSON + */ + public async myPolls(req: Request, res: Response, next: NextFunction): Promise { + try { + const user = req.user + + // Check if user exists + if (!user) { + return res.status(400).json({ + success: false, + message: "User not found", + error: "User not found" + }); + } + + // Fetch polls + const polls = await this.pollService.myPolls(user.id, user.guest); + + res.status(200).json({ + message: "Polls fetched successfully", + data: polls + }); + + } catch (error) { + return res.status(500).json({ + success: false, + message: "Failed to fetch polls", + error: error + }); + } + } + + /** + * Get all polls user has voted ins + * @param req - Request + * @param res - Response + * @param next - NextFunction + * @returns - JSON + */ + + public async myVotedPolls(req: Request, res: Response, next: NextFunction): Promise { + try { + const user = req.user + + // Check if user exists + if (!user) { + return res.status(400).json({ + success: false, + message: "User not found", + error: "User not found" + }); + } + + // Fetch polls + const polls = await this.pollService.myVotedPolls(user.id, user.guest); + + res.status(200).json({ + message: "Polls fetched successfully", + data: polls + }); + + } catch (error) { + return res.status(500).json({ + success: false, + message: "Failed to fetch polls", + error: error + }); + } + } + + public async publicPolls(req: Request, res: Response, next: NextFunction): Promise { + try { + + const page = Number(req.query.page) || 1; + const pageSize = Number(req.query.pageSize) || 10; + const search = req.query.search as string; + const logs = req.query.logs === "true"; + + const polls = await this.pollService.publicPolls( + page, + pageSize, + search, + logs + ); + + res.status(200).json({ + message: "Polls fetched successfully", + data: { polls } + }); + } catch (error) { + return res.status(500).json({ + success: false, + message: "Failed to fetch polls", + error: error + }); + } + } + /** * Get a poll by ID * @param req - Request @@ -67,6 +174,27 @@ export class PollController { try { const { pollId } = req.params; + const user = req.user; + + if (!user) { + return res.status(400).json({ + success: false, + message: "User not found", + error: "User not found" + }); + } + + // Check if user or guest can vote on poll + const canGetPoll = await this.pollService.userCanVote(pollId, user.id, user.guest); + + // If user cannot vote on poll + if (!canGetPoll) { + return res.status(403).json({ + success: false, + message: "You cannot vote on this poll", + error: "You cannot vote on this poll" + }); + } const polls = await this.pollService.getPoll(pollId); From 03a82b31503cedbdd238673e7944f524f6440b37 Mon Sep 17 00:00:00 2001 From: boytur Date: Wed, 19 Feb 2025 11:27:20 +0700 Subject: [PATCH 03/13] =?UTF-8?q?=E2=9C=A8=20feat[PollRoutes]:=20add=20aut?= =?UTF-8?q?hentication=20middleware=20and=20routes=20for=20fetching=20user?= =?UTF-8?q?'s=20polls,=20public=20polls,=20and=20voted=20polls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/poll.routes.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/poll.routes.ts b/backend/src/routes/poll.routes.ts index abafe26..f793857 100644 --- a/backend/src/routes/poll.routes.ts +++ b/backend/src/routes/poll.routes.ts @@ -4,6 +4,10 @@ import { PollService } from "../services/poll.service"; import { PollController } from "../controllers/poll.controller"; import { getAllPollValidator, getPollByIdValidator } from "../utils/validators/poll.util"; import { validateRequest } from "../middlewares/validate.middleware"; +import { UserService } from "../services/user.service"; +import { AuthService } from "../services/auth.service"; +import { CryptoService } from "../services/crypto.service"; +import AuthMiddleware from "../middlewares/auth.middleware"; const router = Router(); @@ -13,9 +17,23 @@ const pollService = new PollService(prisma) const pollController = new PollController(pollService); +const userService = new UserService( + prisma +); -router.get('/', getAllPollValidator(), validateRequest, pollController.getPolls); -router.get('/:pollId', getPollByIdValidator(), pollController.getPoll); +const authService = new AuthService(); +const cryptoService = new CryptoService(); + +const authMiddleware = new AuthMiddleware( + userService, + cryptoService, + authService +) + +router.get('/my-polls', authMiddleware.validateMulti, pollController.myPolls); +router.get('/public-polls', authMiddleware.validateMulti, pollController.publicPolls); +router.get('/my-voted-polls', authMiddleware.validateMulti, pollController.myVotedPolls); +router.get('/:pollId', getPollByIdValidator(), authMiddleware.validateMulti, pollController.getPoll); export { router as pollRouters From 82ad1edf881d5fd50cdf90546400ff25b3aa753a Mon Sep 17 00:00:00 2001 From: boytur Date: Wed, 19 Feb 2025 11:27:40 +0700 Subject: [PATCH 04/13] =?UTF-8?q?=E2=9C=A8=20feat[Polls]:=20add=20EventTag?= =?UTF-8?q?=20component=20and=20integrate=20it=20into=20PollCard=20for=20d?= =?UTF-8?q?isplaying=20event=20information?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Polls.tsx | 90 +++++++++++++++++++------------ 1 file changed, 55 insertions(+), 35 deletions(-) diff --git a/frontend/src/components/Polls.tsx b/frontend/src/components/Polls.tsx index 09d3159..df25891 100644 --- a/frontend/src/components/Polls.tsx +++ b/frontend/src/components/Polls.tsx @@ -2,27 +2,37 @@ import { IPoll } from "@/interfaces/interfaces"; import { DateFormatFull, DateFormatFullTime } from "@/lib/DateFormat"; import { useAuth } from "@/hooks/UseAuth"; import { Link } from "react-router-dom"; -import { CalendarCheck, CalendarClock } from "lucide-react"; +import { CalendarCheck, CalendarClock, Tag } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; -// **Polls List Component** +// Polls List Component export function Polls({ polls }: { readonly polls: IPoll[] }) { return (
- {polls.map((poll) => ( - - ))} + {polls && polls?.map((poll) => ( + + ))}
); } -// **Poll Card Component** +// Event Tag Component +function EventTag({ name }: { name: string }) { + return ( +
+ + {name} +
+ ); +} + +// Poll Card Component function PollCard({ poll }: { readonly poll: IPoll }) { const { user } = useAuth(); - // **Memoized Dates** + // Memoized Dates const now = Date.now(); const pollStartTime = new Date(poll.startVoteAt).getTime(); const pollEndTime = new Date(poll.endVoteAt).getTime(); @@ -34,43 +44,53 @@ function PollCard({ poll }: { readonly poll: IPoll }) { return ( - now ? "" : "bg-red-500 hover:bg-red-400"} text-white`}> - {pollStartTime > now - ? `เริ่มใน${DateFormatFullTime(poll.startVoteAt, "TH-th")}` - : `สิ้นสุดใน${DateFormatFullTime(poll.endVoteAt, "TH-th")}`} - - {poll.question} -

{poll.question}

-

{poll.description}

+ now ? "" : "bg-red-500 hover:bg-red-400"} text-white`}> + {pollStartTime > now + ? `เริ่มใน${DateFormatFullTime(poll.startVoteAt, "TH-th")}` + : `สิ้นสุดใน${DateFormatFullTime(poll.endVoteAt, "TH-th")}`} + + {poll.question} +

{poll.question}

+

{poll.description}

-
-
- - {DateFormatFull(poll.startVoteAt, "TH-th")} -
-
- - {DateFormatFull(poll.endVoteAt, "TH-th")} + {/* Event tag */} + {poll.event && ( +
+ +
+ )} + + {/* Poll Duration */} +
+
+ + {DateFormatFull(poll.startVoteAt, "TH-th")} +
+
+ + {DateFormatFull(poll.endVoteAt, "TH-th")} +
-
- - {hasVoted && ( - - )} + {hasVoted && ( + + )} ); } + +export default Polls; \ No newline at end of file From 90ce64558f0bb078c2514b3768f37e06df15632d Mon Sep 17 00:00:00 2001 From: boytur Date: Wed, 19 Feb 2025 11:27:47 +0700 Subject: [PATCH 05/13] =?UTF-8?q?=E2=9C=A8=20feat[Home]:=20refactor=20poll?= =?UTF-8?q?=20fetching=20logic=20to=20separate=20my=20polls,=20public=20po?= =?UTF-8?q?lls,=20and=20voted=20polls=20with=20loading=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/Home.tsx | 92 +++++++++++++++++++++++++++---------- 1 file changed, 68 insertions(+), 24 deletions(-) diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 2e62c08..8138b97 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,62 +1,106 @@ import { useEffect, useState } from "react"; -import { - Loader2, -} from "lucide-react"; +import { Loader2 } from "lucide-react"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, -} from "@/components/ui/accordion" +} from "@/components/ui/accordion"; import { axiosInstance } from "@/lib/Utils"; -import { IPoll } from "@/interfaces/Interfaces"; +import { IPoll } from "@/interfaces/interfaces"; import { Polls } from "@/components/Polls"; export default function Home() { - const [polls, setPolls] = useState([]); - const [isLoading, setIsLoading] = useState(false); + const [myPolls, setMyPolls] = useState([]); + const [publicPolls, setPublicPolls] = useState([]); + const [myVotedPolls, setMyVotedPolls] = useState([]); + + const [isLoadingMyPolls, setIsLoadingMyPolls] = useState(false); + const [isLoadingPublicPolls, setIsLoadingPublicPolls] = useState(false); useEffect(() => { fetchPolls(); }, []); const fetchPolls = async () => { - setIsLoading(true); + setIsLoadingMyPolls(true); + setIsLoadingPublicPolls(true); + try { - const response = await axiosInstance.get("/polls"); - setPolls(response.data.data.polls); + const [myPollsRes, publicPollsRes, myVotedPolls] = await Promise.all([ + axiosInstance.get("/polls/my-polls"), + axiosInstance.get("/polls/public-polls"), + axiosInstance.get("/polls/my-voted-polls"), + ]); + + setMyPolls(myPollsRes.data.data.polls); + setPublicPolls(publicPollsRes.data.data.polls); + setMyVotedPolls(myVotedPolls.data.data.polls); } catch (error) { console.error("Error fetching polls:", error); } finally { - setIsLoading(false); + setIsLoadingMyPolls(false); + setIsLoadingPublicPolls(false); } }; - if (isLoading) { - return ( -
- -
- ); - } - return (
+ {/* My Polls Section */} -

Poll สำหรับคุณ

+

Poll สำหรับคุณ

+
+ + {isLoadingMyPolls ? ( +
+ +
+ ) : myPolls && myPolls.length === 0 ? ( +
+

ไม่มี Poll สำหรับคุณ

+
+ ) : ( + + )} +
+
+ + {/* My voted polls */} + + + +

Poll ที่คุณเคยโหวต

- + {myVotedPolls && myVotedPolls?.length === 0 ? ( +
+

ไม่มี Poll ที่คุณเคยโหวต

+
+ ) : ( + + )}
- + + {/* Public Polls Section */} + -

Poll สาธารณะ

+

Poll สาธารณะ

- + {isLoadingPublicPolls ? ( +
+ +
+ ) : publicPolls && publicPolls?.length === 0 ? ( +
+

ไม่มี Poll สาธารณะ

+
+ ) : ( + + )}
From 2524d065357255bcdc8db3769050937e87ed3c6b Mon Sep 17 00:00:00 2001 From: boytur Date: Wed, 19 Feb 2025 11:28:05 +0700 Subject: [PATCH 06/13] =?UTF-8?q?=E2=9C=A8feat[Polls]:=20implement=20pagin?= =?UTF-8?q?ation=20for=20poll=20fetching=20to=20improve=20performance=20an?= =?UTF-8?q?d=20user=20experience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/services/poll.service.ts | 369 +++++++++++++++++++++++++++ 1 file changed, 369 insertions(+) diff --git a/backend/src/services/poll.service.ts b/backend/src/services/poll.service.ts index 309aa3b..d9c1d10 100644 --- a/backend/src/services/poll.service.ts +++ b/backend/src/services/poll.service.ts @@ -1,4 +1,5 @@ import { PrismaClient } from "@prisma/client"; +import { DataLog, IEvent, IPoll } from "../interface"; export class PollService { private prisma: PrismaClient; @@ -7,6 +8,14 @@ export class PollService { this.prisma = prisma; } + /** + * Get all polls with pagination + * @param page - number + * @param pageSize - number + * @param search - string + * @param logs - boolean + */ + public async getPolls( page: number = 1, pageSize: number = 10, @@ -49,6 +58,304 @@ export class PollService { } } + /** + * Get all polls where user is a participant or guest + * @param userId - string + * @param isGuest - boolean + * @returns - IPoll[] + */ + + public async myPolls(userId: string, isGuest: boolean, logs?: boolean): Promise<{ polls: IPoll[] }> { + try { + + let polls: IPoll[] = []; + + if (isGuest) { + const event = await this.prisma.event.findFirst({ + where: { + guests: { + some: { + id: userId, + deletedAt: null, + } + } + } + }); + + + if (!event) { + return { polls: [] }; + } + + const rawPolls = await this.prisma.poll.findMany({ + where: { + eventId: event.id, + deletedAt: null, + isVoteEnd: false, + }, + include: { + event: true, + } + }); + + polls = rawPolls.map(poll => ({ + ...poll, + description: poll.description ?? undefined, + banner: poll.banner ?? undefined, + dataLogs: logs ? (poll.dataLogs as unknown as DataLog[] | null) ?? undefined : undefined, + event: poll.event ? { + ...poll.event, + description: poll.event.description ?? undefined, + dataLogs: (poll.event.dataLogs as unknown as DataLog[] | null) ?? undefined, + } : undefined, + })); + } + + if (!isGuest) { + const rawPolls = await this.prisma.poll.findMany({ + where: { + whitelist: { + some: { + userId: userId, + deletedAt: null, + }, + + }, + deletedAt: null, + isVoteEnd: false, + }, + include: { + event: true, + } + }); + + polls = rawPolls.map(poll => ({ + ...poll, + description: poll.description ?? undefined, + banner: poll.banner ?? undefined, + dataLogs: logs ? (poll.dataLogs as unknown as DataLog[] | null) ?? undefined : undefined, + event: poll.event ? { + ...poll.event, + description: poll.event.description ?? undefined, + dataLogs: (poll.event.dataLogs as unknown as DataLog[] | null) ?? undefined, + } : undefined, + })); + + } + + const formattedPolls: IPoll[] = polls.map((poll): IPoll => ({ + ...poll, + description: poll.description ?? undefined, + banner: poll.banner ?? undefined, + dataLogs: logs ? (poll.dataLogs as unknown as DataLog[] | null) ?? undefined : undefined, + })); + + + return { polls: formattedPolls }; + } catch (error) { + console.error("[ERROR] myPolls:", error); + throw new Error("Failed to fetch polls"); + } + } + + + public async myVotedPolls(userId: string, isGuest: boolean, logs?: boolean): Promise<{ polls: IPoll[] }> { + try { + + let polls: IPoll[] = []; + + if (isGuest) { + const event = await this.prisma.event.findFirst({ + where: { + guests: { + some: { + id: userId, + deletedAt: null, + } + } + } + }); + + if (!event) { + return { polls: [] }; + } + + const rawPolls = await this.prisma.poll.findMany({ + where: { + eventId: event.id, + deletedAt: null, + isVoteEnd: true, + }, + include: { + votes: { + where: { + userId: userId, + deletedAt: null, + } + } + } + }); + + polls = rawPolls.map(poll => ({ + ...poll, + description: poll.description ?? undefined, + banner: poll.banner ?? undefined, + dataLogs: logs ? (poll.dataLogs as unknown as DataLog[] | null) ?? undefined : undefined, + votes: poll.votes.map(vote => ({ + ...vote, + dataLogs: (vote.dataLogs as unknown as DataLog[] | null) ?? undefined, + })), + })); + } + + if (!isGuest) { + const event = await this.prisma.event.findFirst({ + where: { + whitelist: { + some: { + userId: userId, + deletedAt: null, + } + } + }, + include: { + polls: { + where: { + isVoteEnd: true, + }, + include: { + votes: { + where: { + userId: userId, + deletedAt: null, + } + } + } + } + } + }); + + if (!event) { + return { polls: [] }; + } + } + + + const rawPolls = await this.prisma.poll.findMany({ + where: { + whitelist: { + some: { + userId: userId, + deletedAt: null, + }, + + }, + deletedAt: null, + isVoteEnd: true, + }, + + include: { + votes: { + where: { + userId: userId, + deletedAt: null, + } + }, + event: true, + } + }); + + + polls = rawPolls.map(poll => ({ + ...poll, + description: poll.description ?? undefined, + banner: poll.banner ?? undefined, + dataLogs: logs ? (poll.dataLogs as unknown as DataLog[] | null) ?? undefined : undefined, + event: poll.event ? { + ...poll.event, + description: poll.event.description ?? undefined, + dataLogs: (poll.event.dataLogs as unknown as DataLog[] | null) ?? undefined, + + } : undefined, + votes: poll.votes.map(vote => ({ + ...vote, + dataLogs: (vote.dataLogs as unknown as DataLog[] | null) ?? undefined, + })), + })); + + return { polls: polls }; + + } catch (error) { + console.error("[ERROR] myPolls:", error); + throw new Error("Failed to fetch polls"); + } + } + + /** + * Get all public polls + * @param page - number + * @param pageSize - number + * @param search - string + * @param logs - boolean + * @returns - IPoll[] + */ + + public async publicPolls( + page: number = 1, + pageSize?: number, + search?: string, + logs?: boolean) { + try { + const skip = pageSize ? (page - 1) * pageSize : undefined; + const take = pageSize || undefined; + + const whereCondition = { + deletedAt: null, + isPublic: true, + isVoteEnd: false, + ...(search && { + name: { contains: search, mode: "insensitive" }, + }), + + }; + + const polls = await this.prisma.poll.findMany({ + where: whereCondition, + skip, + take, + orderBy: { createdAt: "desc" }, + include: { + event: true, + } + }); + + + const formattedPolls = polls.map((poll) => ({ + ...poll, + description: poll.description || undefined, + dataLogs: logs ? poll.dataLogs : undefined, + event: poll.event + ? { + ...poll.event, + description: poll.event.description || undefined, + dataLogs: logs ? poll.event.dataLogs : undefined, + } + : null, + })); + + return formattedPolls; + } catch (error) { + console.error("[ERROR] publicPolls:", error); + throw new Error("Failed to fetch public polls"); + } + } + + /** + * Get a poll by ID + * @param pollId - string + * @returns - IPoll + * @throws - Error + */ public async getPoll(pollId: string) { try { const poll = await this.prisma.poll.findFirst({ @@ -64,10 +371,72 @@ export class PollService { return null; } + // count total votes + + poll.options = poll.options.map((option) => { + const votes = poll.votes.filter((vote) => vote.optionId === option.id); + return { + ...option, + votes: votes.length, + }; + }); + return poll; } catch (error) { console.error("[ERROR] getPoll:", error); throw new Error("Failed to fetch poll"); } } + + /** + * Check if user can vote + * @param pollId - string + * @param userId - string + * @param isGuest - boolean + * @returns - boolean + */ + + public async userCanVote(pollId: string, userId: string, isGuest: boolean) { + try { + + console.log("pollId", pollId); + console.log("userId", userId); + console.log("isGuest", isGuest); + + if (isGuest) { + const vote = await this.prisma.event.findFirst({ + where: { + id: pollId, + guests: { + some: { + id: userId, + deletedAt: null, + } + } + } + }); + } + + const vote = await this.prisma.poll.findFirst({ + where: { + id: pollId, + whitelist: { + some: { + userId: userId, + deletedAt: null, + } + } + } + }) + + + if (!vote) { + return false; + } + + } catch (error) { + console.error("[ERROR] userCanVote:", error); + throw new Error("Failed to check user vote"); + } + } } \ No newline at end of file From 107cd425134241c35528e0ab8de46890b4e81792 Mon Sep 17 00:00:00 2001 From: boytur Date: Wed, 19 Feb 2025 11:52:03 +0700 Subject: [PATCH 07/13] =?UTF-8?q?=F0=9F=90=9B=20fix[PollService]:=20enhanc?= =?UTF-8?q?e=20userCanVote=20method=20to=20improve=20guest=20and=20partici?= =?UTF-8?q?pant=20voting=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/services/poll.service.ts | 34 ++++++++++++++++------------ 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/backend/src/services/poll.service.ts b/backend/src/services/poll.service.ts index d9c1d10..9f3e9b1 100644 --- a/backend/src/services/poll.service.ts +++ b/backend/src/services/poll.service.ts @@ -329,7 +329,7 @@ export class PollService { } }); - + const formattedPolls = polls.map((poll) => ({ ...poll, description: poll.description || undefined, @@ -396,17 +396,19 @@ export class PollService { * @returns - boolean */ - public async userCanVote(pollId: string, userId: string, isGuest: boolean) { + public async userCanVote(pollId: string, userId: string, isGuest: boolean): Promise { try { - console.log("pollId", pollId); - console.log("userId", userId); - console.log("isGuest", isGuest); - + // Check if user is a guest if (isGuest) { - const vote = await this.prisma.event.findFirst({ + const event = await this.prisma.event.findFirst({ where: { - id: pollId, + polls: { + some: { + id: pollId, + isVoteEnd: false, + } + }, guests: { some: { id: userId, @@ -415,24 +417,26 @@ export class PollService { } } }); + + return !!event; } - const vote = await this.prisma.poll.findFirst({ + // Check if user is a participant + const canVote = await this.prisma.poll.findFirst({ where: { id: pollId, + isVoteEnd: false, whitelist: { some: { userId: userId, deletedAt: null, } - } + }, + deletedAt: null, } - }) - + }); - if (!vote) { - return false; - } + return !!canVote; } catch (error) { console.error("[ERROR] userCanVote:", error); From e06a0560ed331e12997e6e621380aa687bebaf42 Mon Sep 17 00:00:00 2001 From: boytur Date: Wed, 19 Feb 2025 11:54:07 +0700 Subject: [PATCH 08/13] =?UTF-8?q?=F0=9F=90=9B=20fix[UseAuth]:=20remove=20a?= =?UTF-8?q?lert=20for=20access=20token=20to=20enhance=20security=20and=20u?= =?UTF-8?q?ser=20experience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/hooks/UseAuth.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/hooks/UseAuth.tsx b/frontend/src/hooks/UseAuth.tsx index 1e54012..42fbf17 100644 --- a/frontend/src/hooks/UseAuth.tsx +++ b/frontend/src/hooks/UseAuth.tsx @@ -68,7 +68,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { setIsAuthenticated(true) setAccessToken(credentials.accessToken) - alert(credentials.accessToken) localStorage.setItem('accessToken', credentials.accessToken) window.location.href = redirect } finally { From bf0c0a0eb2da4fb5a714e3772a6bb569835b0b04 Mon Sep 17 00:00:00 2001 From: boytur Date: Wed, 19 Feb 2025 12:50:06 +0700 Subject: [PATCH 09/13] =?UTF-8?q?=F0=9F=91=B7=20build[depedencies]:=20add?= =?UTF-8?q?=20@radix-ui/react-checkbox=20dependency=20to=20enhance=20UI=20?= =?UTF-8?q?component=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 31 +++++++++++++++++++++++++++++++ frontend/package.json | 3 ++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ed90bdb..de7b971 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-icons": "^1.3.2", @@ -1203,6 +1204,36 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.4.tgz", + "integrity": "sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collapsible": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 07a4019..d5d1ad1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-icons": "^1.3.2", @@ -33,8 +34,8 @@ "dotenv": "^16.4.7", "lucide-react": "^0.344.0", "motion": "^12.4.2", - "qr-scanner": "^1.4.2", "next-themes": "^0.4.4", + "qr-scanner": "^1.4.2", "react": "^18.3.1", "react-barcode-qrcode-scanner": "^0.1.3", "react-day-picker": "^8.10.1", From 7e1c022f457bc8f9e1beb1426ca3fddc8476dfec Mon Sep 17 00:00:00 2001 From: boytur Date: Wed, 19 Feb 2025 12:50:14 +0700 Subject: [PATCH 10/13] =?UTF-8?q?=E2=9C=A8=20feat[QrcodeScanner]:=20enhanc?= =?UTF-8?q?e=20QR=20code=20scanning=20experience=20with=20error=20handling?= =?UTF-8?q?=20and=20retry=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/qrcode/QrcodeScanner.tsx | 240 +++++++++++++--- frontend/src/pages/auth/LogIn.tsx | 267 ++++++++++-------- 2 files changed, 354 insertions(+), 153 deletions(-) diff --git a/frontend/src/components/qrcode/QrcodeScanner.tsx b/frontend/src/components/qrcode/QrcodeScanner.tsx index a518fb6..706e6d0 100644 --- a/frontend/src/components/qrcode/QrcodeScanner.tsx +++ b/frontend/src/components/qrcode/QrcodeScanner.tsx @@ -1,49 +1,217 @@ import React, { useEffect, useRef, useState } from 'react'; import QrScanner from 'qr-scanner'; -import 'tailwindcss/tailwind.css'; +import { Button } from "@/components/ui/button"; +import { Loader2, CheckCircle2, AlertTriangle } from "lucide-react"; interface QRScannerProps { - onResult: (result: any) => void; - } + onResult: (result: { text: string } | null) => void; +} -const QRScanner: React.FC = () => { - const videoElementRef = useRef(null); - const [scanned, setScannedText] = useState(''); +const QRScanner: React.FC = ({ onResult }) => { + const videoElementRef = useRef(null); + const [scanning, setScanning] = useState(true); + const [scannedText, setScannedText] = useState(''); + const [error, setError] = useState(''); + const [qrScannerInstance, setQrScannerInstance] = useState(null); - useEffect(() => { - const video = videoElementRef.current; - if (!video) return; + useEffect(() => { + const video = videoElementRef.current; + if (!video) return; + // Reset states when component mounts + setScanning(true); + setScannedText(''); + setError(''); + + try { + const qrScanner = new QrScanner( + video, + (result: QrScanner.ScanResult) => { + console.log('Decoded QR code:', result); + setScannedText(result.data); + setScanning(false); + onResult({ text: result.data }); + }, + { + returnDetailedScanResult: true, + highlightScanRegion: true, + highlightCodeOutline: true, + calculateScanRegion: (video) => { + const smallerDimension = Math.min(video.videoWidth, video.videoHeight); + const scanRegionSize = Math.round(smallerDimension * 0.7); + + return { + x: Math.round((video.videoWidth - scanRegionSize) / 2), + y: Math.round((video.videoHeight - scanRegionSize) / 2), + width: scanRegionSize, + height: scanRegionSize, + }; + } + } + ); + + qrScanner.start().catch(error => { + console.error('QR Scanner start error:', error); + setError('Could not access camera. Please check permissions.'); + setScanning(false); + }); + + setQrScannerInstance(qrScanner); + + return () => { + qrScanner.stop(); + qrScanner.destroy(); + }; + } catch (err) { + console.error('QR Scanner initialization error:', err); + setError('Failed to initialize camera. Please try again.'); + setScanning(false); + } + }, [onResult]); + + const handleRetry = () => { + if (qrScannerInstance) { + qrScannerInstance.stop(); + qrScannerInstance.destroy(); + setQrScannerInstance(null); + } + + setScanning(true); + setScannedText(''); + setError(''); + + // Re-trigger the useEffect + setTimeout(() => { + const video = videoElementRef.current; + if (!video) return; + + try { const qrScanner = new QrScanner( - video, - (result: QrScanner.ScanResult) => { - console.log('decoded qr code:', result); - setScannedText(result.data); - }, - { - returnDetailedScanResult: true, - highlightScanRegion: true, - highlightCodeOutline: true, - } + video, + (result: QrScanner.ScanResult) => { + console.log('Decoded QR code:', result); + setScannedText(result.data); + setScanning(false); + onResult({ text: result.data }); + }, + { + returnDetailedScanResult: true, + highlightScanRegion: true, + highlightCodeOutline: true, + } ); - qrScanner.start(); - console.log('start'); - - return () => { - console.log(qrScanner); - qrScanner.stop(); - qrScanner.destroy(); - }; - }, []); - - return ( -
-
-