From 8d453fd9afa4a728cefa3f92613d3de5a21290b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:02:48 +0000 Subject: [PATCH 1/4] refactor: unify eligible voter count source and clean up common smells Agent-Logs-Url: https://github.com/NTHU-SA/Voting-System/sessions/b318b56e-a85a-4c80-8b1e-74ce50bfb31c Co-authored-by: l7wei <87221670+l7wei@users.noreply.github.com> --- Dockerfile | 6 +- app/admin/activities/[id]/page.tsx | 14 +- .../activities/[id]/verification/page.tsx | 6 +- .../_components/ActivityFormFields.tsx | 2 +- .../_components/CandidateFormFields.tsx | 2 +- .../_components/OptionFormSection.tsx | 29 +-- .../_components/ViceCandidateSection.tsx | 4 +- .../activities/_components/useOptionForm.ts | 14 +- app/admin/settings/page.tsx | 58 +++--- app/api/activities/[id]/route.ts | 33 ++-- app/api/activities/[id]/voters/route.ts | 9 +- app/api/mock/authorize/page.tsx | 24 +-- app/api/mock/resource/route.ts | 2 +- app/api/votes/route.ts | 3 +- app/layout.tsx | 4 +- app/login/page.tsx | 2 +- app/verify/page.tsx | 10 +- app/vote/[id]/completion/page.tsx | 142 +++++++-------- app/vote/[id]/page.tsx | 104 ++++++----- app/vote/certificate/page.tsx | 11 +- components/ActivityStatusBadge.tsx | 4 +- components/Header.tsx | 170 +++++++++--------- components/LoginModal.tsx | 11 +- components/MarkdownRenderer.tsx | 26 +-- components/ui/card.tsx | 8 +- components/ui/loader.tsx | 8 +- lib/activityVoterService.ts | 5 + lib/statisticsService.ts | 4 +- 28 files changed, 372 insertions(+), 343 deletions(-) create mode 100644 lib/activityVoterService.ts diff --git a/Dockerfile b/Dockerfile index c0ab8e4..66db484 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,13 +25,11 @@ WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs +RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public -RUN mkdir .next -RUN chown nextjs:nodejs .next +RUN mkdir .next && chown nextjs:nodejs .next COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static diff --git a/app/admin/activities/[id]/page.tsx b/app/admin/activities/[id]/page.tsx index 441c1f7..1debbb6 100644 --- a/app/admin/activities/[id]/page.tsx +++ b/app/admin/activities/[id]/page.tsx @@ -365,13 +365,13 @@ function ActivityDetailPageContent() { body: formData, }); const data = await response.json(); - if (!data.success) { - setError(data.error || "上傳選民名冊失敗"); - } else { + if (data.success) { setSuccessMessage(`選民名冊上傳成功,共 ${data.data.eligible_voters_count} 人`); setVoterCsvFile(null); await fetchVoterStats(); await refetch(); + } else { + setError(data.error || "上傳選民名冊失敗"); } } catch (err) { console.error("Error uploading voter list:", err); @@ -670,9 +670,11 @@ function ActivityDetailPageContent() { )} - {option.vice && - option.vice.map((vice, viceIndex) => ( -
+ {option.vice?.map((vice, viceIndex) => ( +
副選 {viceIndex + 1}:{" "} diff --git a/app/admin/activities/[id]/verification/page.tsx b/app/admin/activities/[id]/verification/page.tsx index 7ee80c5..aa2c143 100644 --- a/app/admin/activities/[id]/verification/page.tsx +++ b/app/admin/activities/[id]/verification/page.tsx @@ -61,14 +61,14 @@ function VerificationPageContent() { if (!authData.authenticated || !authData.user?.isAdmin) { // Not authenticated or not an admin, redirect to home - window.location.href = "/?error=admin_required"; + globalThis.location.href = "/?error=admin_required"; return; } fetchVerificationData(); } catch (err) { console.error("Error checking admin access:", err); - window.location.href = "/?error=auth_failed"; + globalThis.location.href = "/?error=auth_failed"; } }; @@ -135,7 +135,7 @@ function VerificationPageContent() { link.style.visibility = "hidden"; document.body.appendChild(link); link.click(); - document.body.removeChild(link); + link.remove(); }; if (loading) { diff --git a/app/admin/activities/_components/ActivityFormFields.tsx b/app/admin/activities/_components/ActivityFormFields.tsx index 10bfa03..38a616f 100644 --- a/app/admin/activities/_components/ActivityFormFields.tsx +++ b/app/admin/activities/_components/ActivityFormFields.tsx @@ -25,7 +25,7 @@ export function ActivityFormFields({ formData, onChange, disabled = false, -}: ActivityFormFieldsProps) { +}: Readonly) { return (
diff --git a/app/admin/activities/_components/CandidateFormFields.tsx b/app/admin/activities/_components/CandidateFormFields.tsx index cfe80b5..9b2e115 100644 --- a/app/admin/activities/_components/CandidateFormFields.tsx +++ b/app/admin/activities/_components/CandidateFormFields.tsx @@ -16,7 +16,7 @@ export function CandidateFormFields({ onChange, label, required = false, -}: CandidateFormFieldsProps) { +}: Readonly) { return (

{label}

diff --git a/app/admin/activities/_components/OptionFormSection.tsx b/app/admin/activities/_components/OptionFormSection.tsx index d3871fd..f7e53af 100644 --- a/app/admin/activities/_components/OptionFormSection.tsx +++ b/app/admin/activities/_components/OptionFormSection.tsx @@ -37,29 +37,28 @@ export function OptionFormSection({ editOption, removeOption, resetForm, -}: OptionFormSectionProps) { +}: Readonly) { const handleAddOrUpdate = () => { - if (!currentOption.candidate.name) { - return; + if (currentOption.candidate.name) { + addOrUpdateOption(); } - addOrUpdateOption(); }; const handleRemove = (index: number) => { removeOption(index); }; + const cardTitle = + editingIndex === null + ? `新增候選人組合 #${options.length + 1}` + : `編輯候選人 #${editingIndex + 1}`; + return (
{/* Current option form */} - - {editingIndex !== null - ? `編輯候選人 #${editingIndex + 1}` - : `新增候選人組合 #${options.length + 1}` - } - + {cardTitle}
@@ -134,7 +133,12 @@ export function OptionFormSection({ 已新增的候選人 ({options.length}) {options.map((option, index) => ( - + v.name) + .join("-")}`} + className={editingIndex === index ? "border-primary" : ""} + >

@@ -176,5 +180,4 @@ export function OptionFormSection({ ); } -// Export the hook for external use -export { useOptionForm }; +export { useOptionForm } from "./useOptionForm"; diff --git a/app/admin/activities/_components/ViceCandidateSection.tsx b/app/admin/activities/_components/ViceCandidateSection.tsx index adbdaca..0288677 100644 --- a/app/admin/activities/_components/ViceCandidateSection.tsx +++ b/app/admin/activities/_components/ViceCandidateSection.tsx @@ -17,12 +17,12 @@ export function ViceCandidateSection({ onAddVice, onRemoveVice, onViceChange, -}: ViceCandidateSectionProps) { +}: Readonly) { return (

{vices.map((vice, index) => (
+
+ ))} +
+ ); + return (
@@ -162,34 +191,7 @@ export default function AdminSettingsPage() { - {loading ? ( -

載入中...

- ) : admins.length === 0 ? ( -

尚無資料

- ) : ( -
- {admins.map((admin) => ( -
-
-

{admin.student_id}

- {admin.name && ( -

{admin.name}

- )} -
- -
- ))} -
- )} + {adminListContent}
diff --git a/app/api/activities/[id]/route.ts b/app/api/activities/[id]/route.ts index da0b13a..c8445a0 100644 --- a/app/api/activities/[id]/route.ts +++ b/app/api/activities/[id]/route.ts @@ -12,6 +12,22 @@ import connectDB from "@/lib/db"; import { validateDateRange, isValidRule } from "@/lib/validation"; import { API_CONSTANTS } from "@/lib/constants"; +function buildActivityUpdateData(body: Record) { + const { name, type, description, rule, open_from, open_to } = body; + const updateData: Record = { + updated_at: new Date(), + }; + + if (name) updateData.name = name; + if (type) updateData.type = type; + if (description !== undefined) updateData.description = description; + if (rule) updateData.rule = rule; + if (open_from) updateData.open_from = new Date(open_from as string); + if (open_to) updateData.open_to = new Date(open_to as string); + + return updateData; +} + // GET /api/activities/[id] - Get single activity export async function GET( request: NextRequest, @@ -72,7 +88,7 @@ export async function PUT( return invalidIdResponse; } - const body = await request.json(); + const body = (await request.json()) as Record; const { name, type, description, rule, open_from, open_to } = body; // Validate rule if provided @@ -82,8 +98,8 @@ export async function PUT( // Validate dates if provided if (open_from && open_to) { - const openFrom = new Date(open_from); - const openTo = new Date(open_to); + const openFrom = new Date(open_from as string); + const openTo = new Date(open_to as string); const dateValidation = validateDateRange(openFrom, openTo); if (!dateValidation.valid) { @@ -91,16 +107,7 @@ export async function PUT( } } - const updateData: Record = { - updated_at: new Date(), - }; - - if (name) updateData.name = name; - if (type) updateData.type = type; - if (description !== undefined) updateData.description = description; - if (rule) updateData.rule = rule; - if (open_from) updateData.open_from = new Date(open_from); - if (open_to) updateData.open_to = new Date(open_to); + const updateData = buildActivityUpdateData(body); const activity = await Activity.findByIdAndUpdate(id, updateData, { new: true, diff --git a/app/api/activities/[id]/voters/route.ts b/app/api/activities/[id]/voters/route.ts index 6984bb2..06e4a50 100644 --- a/app/api/activities/[id]/voters/route.ts +++ b/app/api/activities/[id]/voters/route.ts @@ -12,6 +12,7 @@ import connectDB from "@/lib/db"; import { Activity } from "@/lib/models/Activity"; import { ActivityVoter } from "@/lib/models/ActivityVoter"; import { API_CONSTANTS } from "@/lib/constants"; +import { getEligibleVotersCount } from "@/lib/activityVoterService"; function extractStudentIds(csvText: string): string[] { const records = parse(csvText, { @@ -151,7 +152,7 @@ export async function GET( return createErrorResponse(API_CONSTANTS.ERRORS.ACTIVITY_NOT_FOUND, 404); } - const count = await ActivityVoter.countDocuments({ activity_id: id }); + const count = await getEligibleVotersCount(id); return createSuccessResponse({ activity_id: id, @@ -286,9 +287,7 @@ export async function POST( const supportsTransactions = await supportsMongoTransactions(db); - if (!supportsTransactions) { - await replaceVotersWithoutTransaction(); - } else { + if (supportsTransactions) { const session = await db.startSession(); try { await session.withTransaction(async () => { @@ -302,6 +301,8 @@ export async function POST( } finally { await session.endSession(); } + } else { + await replaceVotersWithoutTransaction(); } return createSuccessResponse({ diff --git a/app/api/mock/authorize/page.tsx b/app/api/mock/authorize/page.tsx index 76c8655..9cc4f77 100644 --- a/app/api/mock/authorize/page.tsx +++ b/app/api/mock/authorize/page.tsx @@ -41,22 +41,22 @@ function MockAuthContent() { const uuid = formData.uuid || `mock-uuid-${Date.now()}`; // Prepare data based on requested scope - const scopeFields = scope.split(" "); + const scopeFields = new Set(scope.split(" ")); const mockData: Record = { timestamp: Date.now().toString(), }; // Only include fields that are in the requested scope - if (scopeFields.includes("userid")) { + if (scopeFields.has("userid")) { mockData.Userid = formData.userid; } - if (scopeFields.includes("name")) { + if (scopeFields.has("name")) { mockData.name = formData.name; } - if (scopeFields.includes("inschool")) { + if (scopeFields.has("inschool")) { mockData.inschool = formData.inschool; } - if (scopeFields.includes("uuid")) { + if (scopeFields.has("uuid")) { mockData.uuid = uuid; } @@ -82,7 +82,7 @@ function MockAuthContent() { if (state) { callbackUrl.searchParams.set("state", state); } - window.location.href = callbackUrl.toString(); + globalThis.location.href = callbackUrl.toString(); } catch (error) { console.error("Error during mock OAuth:", error); setIsSubmitting(false); @@ -119,7 +119,7 @@ function MockAuthContent() { ); } - const scopeFields = scope.split(" "); + const scopeFields = new Set(scope.split(" ")); return ( @@ -134,7 +134,7 @@ function MockAuthContent() {
- {scopeFields.includes("userid") && ( + {scopeFields.has("userid") && (
)} - {scopeFields.includes("name") && ( + {scopeFields.has("name") && (
)} - {scopeFields.includes("inschool") && ( + {scopeFields.has("inschool") && (
diff --git a/app/api/mock/resource/route.ts b/app/api/mock/resource/route.ts index 5972745..4333de5 100644 --- a/app/api/mock/resource/route.ts +++ b/app/api/mock/resource/route.ts @@ -16,7 +16,7 @@ export async function POST(request: NextRequest) { let mockData = null; - if (authHeader && authHeader.startsWith("Bearer ")) { + if (authHeader?.startsWith("Bearer ")) { const accessToken = authHeader.substring(7); // Retrieve mock data from store using access token mockData = mockAuthStore.get(accessToken); diff --git a/app/api/votes/route.ts b/app/api/votes/route.ts index 90b132d..2149f37 100644 --- a/app/api/votes/route.ts +++ b/app/api/votes/route.ts @@ -12,8 +12,7 @@ import { ActivityVoter } from "@/lib/models/ActivityVoter"; import { Option } from "@/lib/models/Option"; import connectDB from "@/lib/db"; import { createVote } from "@/lib/votingService"; -import { isValidRule } from "@/lib/validation"; -import { validatePagination } from "@/lib/validation"; +import { isValidRule, validatePagination } from "@/lib/validation"; import { API_CONSTANTS } from "@/lib/constants"; export async function POST(request: NextRequest) { diff --git a/app/layout.tsx b/app/layout.tsx index f6cd996..b7a8475 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -11,9 +11,9 @@ export const metadata: Metadata = { export default function RootLayout({ children, -}: { +}: Readonly<{ children: React.ReactNode; -}) { +}>) { return ( {children} diff --git a/app/login/page.tsx b/app/login/page.tsx index 6dbbf78..226bf75 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -11,7 +11,7 @@ function LoginContent() { useEffect(() => { // Auto-redirect to OAuth login - window.location.href = + globalThis.location.href = "/api/auth/login" + (redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""); }, [redirect]); diff --git a/app/verify/page.tsx b/app/verify/page.tsx index 236ef14..3ee57f1 100644 --- a/app/verify/page.tsx +++ b/app/verify/page.tsx @@ -38,7 +38,9 @@ export default function VerifyPage() { const response = await fetch(`/api/verify/${encodeURIComponent(uuid.trim())}`); const data = await response.json(); - if (!data.success) { + if (data.success) { + setResult(data.data); + } else { if (response.status === 404) { setError("查無此 UUID 投票記錄"); } else if (response.status === 400) { @@ -46,8 +48,6 @@ export default function VerifyPage() { } else { setError(data.error || "查詢失敗"); } - } else { - setResult(data.data); } } catch { setError("查詢時發生錯誤"); @@ -105,8 +105,8 @@ export default function VerifyPage() { 投票內容

    - {result.selections.map((selection, index) => ( -
  • {selection}
  • + {result.selections.map((selection) => ( +
  • {selection}
  • ))}
diff --git a/app/vote/[id]/completion/page.tsx b/app/vote/[id]/completion/page.tsx index 105a6c4..9104a8b 100644 --- a/app/vote/[id]/completion/page.tsx +++ b/app/vote/[id]/completion/page.tsx @@ -29,6 +29,76 @@ export default function CompletionPage() { allActivities.length > 0 && allActivities.every((act) => votedActivityIds.includes(act._id)); + const nextStepContent = allVoted ? ( +
+ + + +

🎉 恭喜!您已完成所有投票活動

+

+ 您已經投完所有開放中的投票活動,感謝您的參與! +

+
+
+
+ + +
+
+ ) : nextActivity ? ( +
+ + +

下一個投票活動

+

+ {nextActivity.name} +

+
+
+
+ + +
+
+ ) : ( + + ); + return (
@@ -104,77 +174,7 @@ export default function CompletionPage() { )} {/* Next Steps */} - {allVoted ? ( -
- - - -

- 🎉 恭喜!您已完成所有投票活動 -

-

- 您已經投完所有開放中的投票活動,感謝您的參與! -

-
-
-
- - -
-
- ) : nextActivity ? ( -
- - -

下一個投票活動

-

- {nextActivity.name} -

-
-
-
- - -
-
- ) : ( - - )} + {nextStepContent}
); diff --git a/app/vote/[id]/page.tsx b/app/vote/[id]/page.tsx index ee63138..881de41 100644 --- a/app/vote/[id]/page.tsx +++ b/app/vote/[id]/page.tsx @@ -106,7 +106,7 @@ export default function VotingPage() { useEffect(() => { // Initialize vote state for choose_all when activity loads (only if no existing vote) - if (activity && activity.rule === "choose_all" && !hasExistingVote && !loadingVote) { + if (activity?.rule === "choose_all" && !hasExistingVote && !loadingVote) { setChooseAllVotes((prev) => { const nextVotes = { ...prev }; let changed = false; @@ -183,13 +183,11 @@ export default function VotingPage() { router.push( `/vote/${activityId}/completion?token=${data.data.token}&name=${encodeURIComponent(activity.name)}`, ); - } else { + } else if (data.error === "User has already voted") { // Check if user has already voted - if (data.error === "User has already voted") { - setError(API_CONSTANTS.MESSAGES.VOTE_ALREADY_VOTED_NO_TOKEN.join("\n")); - } else { - setError(data.error || "投票失敗"); - } + setError(API_CONSTANTS.MESSAGES.VOTE_ALREADY_VOTED_NO_TOKEN.join("\n")); + } else { + setError(data.error || "投票失敗"); } } catch (err) { console.error("Error submitting vote:", err); @@ -258,8 +256,8 @@ export default function VotingPage() {
    {candidate.personal_experiences.map( - (exp: string, idx: number) => ( -
  • + (exp: string) => ( +
    • {candidate.political_opinions.map( - (opinion: string, idx: number) => ( -
    • + (opinion: string) => ( +
    • 投票說明: + {" "} 請對每位候選人表達您的意見(支持、反對或無意見) @@ -364,48 +366,45 @@ export default function VotingPage() { {/* Status Message */} {error && ( - <> - {/* 浮動提示框 */} -
      - + + + {hasExistingVote ? ( + + ) : ( + )} - > - - {hasExistingVote ? ( - - ) : ( - + +

      + {error} +

      + {!hasExistingVote && ( + - )} -
      -
      -
      - + 關閉 + + + + + )} + + +
)} {/* Options/Candidates */} @@ -420,9 +419,8 @@ export default function VotingPage() { {option.candidate && renderCandidate(option.candidate)} - {option.vice && - option.vice.map((vice, viceIndex) => ( -
+ {option.vice?.map((vice) => ( +
{renderCandidate(vice)}
))} diff --git a/app/vote/certificate/page.tsx b/app/vote/certificate/page.tsx index d17baeb..e0c007c 100644 --- a/app/vote/certificate/page.tsx +++ b/app/vote/certificate/page.tsx @@ -28,12 +28,12 @@ export default function CompletionPage() { }; const handlePrint = () => { - window.print(); + globalThis.print(); }; const handleClearHistory = () => { if ( - window.confirm(API_CONSTANTS.MESSAGES.CONFIRM_CLEAR_ALL_HISTORY) + globalThis.confirm(API_CONSTANTS.MESSAGES.CONFIRM_CLEAR_ALL_HISTORY) ) { clearVotingHistory(); setVotingHistory({ votedActivityIds: [], votes: [] }); @@ -42,7 +42,7 @@ export default function CompletionPage() { const handleRemoveVote = (token: string, activityName: string) => { if ( - window.confirm(API_CONSTANTS.MESSAGES.CONFIRM_REMOVE_VOTE(activityName)) + globalThis.confirm(API_CONSTANTS.MESSAGES.CONFIRM_REMOVE_VOTE(activityName)) ) { const updatedHistory = removeVoteRecordByToken(token); setVotingHistory(updatedHistory); @@ -139,7 +139,7 @@ export default function CompletionPage() {
{votingHistory.votes.map((vote, index) => (
@@ -221,6 +221,7 @@ export default function CompletionPage() {

📌 + {" "} 重要提醒

    @@ -276,7 +277,7 @@ export default function CompletionPage() {
-