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/__tests__/api.activities.id.test.ts b/__tests__/api.activities.id.test.ts new file mode 100644 index 0000000..2a0235f --- /dev/null +++ b/__tests__/api.activities.id.test.ts @@ -0,0 +1,123 @@ +/** @jest-environment node */ + +import { NextResponse } from "next/server"; +import { PUT } from "@/app/api/activities/[id]/route"; +import { API_CONSTANTS } from "@/lib/constants"; + +jest.mock("@/lib/db", () => jest.fn()); +jest.mock("@/lib/middleware", () => ({ + requireAdminAuth: jest.fn(), + validateObjectIdOrError: (id: string) => { + if (!/^[0-9a-fA-F]{24}$/.test(id)) { + return NextResponse.json( + { success: false, error: API_CONSTANTS.ERRORS.INVALID_OBJECT_ID }, + { status: 400 }, + ); + } + return null; + }, + createInternalErrorResponse: (error: unknown, fallbackMessage: string) => + NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : fallbackMessage, + }, + { status: 500 }, + ), + createErrorResponse: (message: string, status = 400) => + NextResponse.json({ success: false, error: message }, { status }), + createSuccessResponse: (data: unknown, status = 200) => + NextResponse.json({ success: true, data }, { status }), +})); +jest.mock("@/lib/models/Activity", () => ({ + Activity: { + findByIdAndUpdate: jest.fn(), + }, +})); +jest.mock("@/lib/models/Option", () => ({ + Option: {}, +})); + +const connectDBMock = jest.requireMock("@/lib/db") as jest.Mock; +const middlewareMock = jest.requireMock("@/lib/middleware") as { + requireAdminAuth: jest.Mock; +}; +const activityModelMock = ( + jest.requireMock("@/lib/models/Activity") as { + Activity: { + findByIdAndUpdate: jest.Mock; + }; + } +).Activity; + +const activityId = "507f1f77bcf86cd799439011"; + +function createRequest(body: unknown) { + return { + json: jest.fn().mockResolvedValue(body), + } as never; +} + +describe("/api/activities/[id] PUT", () => { + beforeEach(() => { + jest.clearAllMocks(); + connectDBMock.mockResolvedValue({}); + middlewareMock.requireAdminAuth.mockResolvedValue({ + student_id: "111000001", + }); + activityModelMock.findByIdAndUpdate.mockResolvedValue({ + _id: activityId, + name: "Updated Activity", + }); + }); + + it("updates activity with partial payload", async () => { + const response = await PUT(createRequest({ name: "Updated Activity" }), { + params: Promise.resolve({ id: activityId }), + }); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(activityModelMock.findByIdAndUpdate).toHaveBeenCalledWith( + activityId, + expect.objectContaining({ + name: "Updated Activity", + updated_at: expect.any(Date), + }), + { + new: true, + runValidators: true, + }, + ); + }); + + it("returns 400 when rule is invalid", async () => { + const response = await PUT(createRequest({ rule: "invalid_rule" }), { + params: Promise.resolve({ id: activityId }), + }); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.success).toBe(false); + expect(body.error).toBe(API_CONSTANTS.ERRORS.INVALID_RULE); + expect(activityModelMock.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + + it("returns 400 when date range is invalid", async () => { + const response = await PUT( + createRequest({ + open_from: "2026-01-02T00:00:00.000Z", + open_to: "2026-01-01T00:00:00.000Z", + }), + { + params: Promise.resolve({ id: activityId }), + }, + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.success).toBe(false); + expect(activityModelMock.findByIdAndUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/app/admin/activities/[id]/page.tsx b/app/admin/activities/[id]/page.tsx index 441c1f7..e4463a3 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..5fe37be 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,10 @@ export function OptionFormSection({ 已新增的候選人 ({options.length}) {options.map((option, index) => ( - +

@@ -176,5 +178,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..f134a78 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..3b18b48 100644 --- a/app/api/activities/[id]/route.ts +++ b/app/api/activities/[id]/route.ts @@ -12,6 +12,31 @@ import connectDB from "@/lib/db"; import { validateDateRange, isValidRule } from "@/lib/validation"; import { API_CONSTANTS } from "@/lib/constants"; +interface ActivityUpdateBody { + name?: string; + type?: string; + description?: string; + rule?: string; + open_from?: string; + open_to?: string; +} + +function buildActivityUpdateData(body: ActivityUpdateBody) { + 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); + if (open_to) updateData.open_to = new Date(open_to); + + return updateData; +} + // GET /api/activities/[id] - Get single activity export async function GET( request: NextRequest, @@ -72,8 +97,18 @@ export async function PUT( return invalidIdResponse; } - const body = await request.json(); - const { name, type, description, rule, open_from, open_to } = body; + const rawBody = (await request.json()) as Record; + const body: ActivityUpdateBody = { + name: typeof rawBody.name === "string" ? rawBody.name : undefined, + type: typeof rawBody.type === "string" ? rawBody.type : undefined, + description: + typeof rawBody.description === "string" ? rawBody.description : undefined, + rule: typeof rawBody.rule === "string" ? rawBody.rule : undefined, + open_from: + typeof rawBody.open_from === "string" ? rawBody.open_from : undefined, + open_to: typeof rawBody.open_to === "string" ? rawBody.open_to : undefined, + }; + const { rule, open_from, open_to } = body; // Validate rule if provided if (rule && !isValidRule(rule)) { @@ -82,8 +117,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 +126,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..eb84716 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("查詢時發生錯誤"); @@ -106,7 +106,7 @@ export default function VerifyPage() {

    {result.selections.map((selection, index) => ( -
  • {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..f3a705f 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) => ( +
    • - {/* 浮動提示框 */} -
      - + + + {hasExistingVote ? ( + + ) : ( + )} - > - - {hasExistingVote ? ( - - ) : ( - + +

      + {error} +

      + {!hasExistingVote && ( + - )} -
      -
      -
      - + 關閉 + + + + + )} + + +
)} {/* Options/Candidates */} @@ -420,9 +418,10 @@ export default function VotingPage() { {option.candidate && renderCandidate(option.candidate)} - {option.vice && - option.vice.map((vice, viceIndex) => ( -
+ {option.vice?.map((vice, viceIndex) => ( +
{renderCandidate(vice)}
))} diff --git a/app/vote/certificate/page.tsx b/app/vote/certificate/page.tsx index d17baeb..60e2563 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) => (
diff --git a/components/ActivityStatusBadge.tsx b/components/ActivityStatusBadge.tsx index 50a5344..e79000a 100644 --- a/components/ActivityStatusBadge.tsx +++ b/components/ActivityStatusBadge.tsx @@ -13,7 +13,9 @@ interface ActivityStatusBadgeProps { * ActivityStatusBadge component * Displays a badge showing the current status of an activity (upcoming, active, or ended) */ -export function ActivityStatusBadge({ activity }: ActivityStatusBadgeProps) { +export function ActivityStatusBadge({ + activity, +}: Readonly) { const status = getActivityStatus(activity); switch (status) { diff --git a/components/Header.tsx b/components/Header.tsx index a12c327..db36234 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -62,12 +62,95 @@ export default function Header() { method: "GET", credentials: "include", }); - window.location.href = "/"; + globalThis.location.href = "/"; } catch (error) { console.error("Logout error:", error); } }; + const authContent = loading ? ( +
+ ) : user ? ( + + + + + + +
+

+ {user.isAdmin && "[管理員] "} + {user.name} +

+

+ {user.student_id} +

+
+
+ + + + + 投票活動 + + + + + + 投票證明 + + + + + + 公開驗票 + + + {user.isAdmin && ( + <> + + + + + 管理後台 + + + {user.isRootAdmin && ( + + + + 管理員設定 + + + )} + + )} + + + + 登出 + +
+
+ ) : ( + + ); + return (
@@ -84,90 +167,7 @@ export default function Header() {
- {loading ? ( -
- ) : user ? ( - <> - - - - - - -
-

- {user.isAdmin && "[管理員] "} - {user.name} -

-

- {user.student_id} -

-
-
- - - - - 投票活動 - - - - - - 投票證明 - - - - - - 公開驗票 - - - {user.isAdmin && ( - <> - - - - - 管理後台 - - - {user.isRootAdmin && ( - - - - 管理員設定 - - - )} - - )} - - - - 登出 - -
-
- - ) : ( - - )} + {authContent}
diff --git a/components/LoginModal.tsx b/components/LoginModal.tsx index 65a4eb1..5598b7b 100644 --- a/components/LoginModal.tsx +++ b/components/LoginModal.tsx @@ -8,7 +8,10 @@ interface LoginModalProps { onClose?: () => void; } -export default function LoginModal({ isOpen, onClose }: LoginModalProps) { +export default function LoginModal({ + isOpen, + onClose, +}: Readonly) { const [isLoading, setIsLoading] = useState(false); const isDev = isDevelopment(); @@ -30,11 +33,11 @@ export default function LoginModal({ isOpen, onClose }: LoginModalProps) { if (isDev) { // In development, redirect to mock OAuth page - const redirectUri = `${window.location.origin}/api/auth/callback`; - window.location.href = `/login?redirect_uri=${encodeURIComponent(redirectUri)}`; + const redirectUri = `${globalThis.location.origin}/api/auth/callback`; + globalThis.location.href = `/login?redirect_uri=${encodeURIComponent(redirectUri)}`; } else { // In production, redirect to OAuth login - window.location.href = "/api/auth/login"; + globalThis.location.href = "/api/auth/login"; } }; diff --git a/components/MarkdownRenderer.tsx b/components/MarkdownRenderer.tsx index 500d3fb..bdab506 100644 --- a/components/MarkdownRenderer.tsx +++ b/components/MarkdownRenderer.tsx @@ -1,6 +1,7 @@ "use client"; import ReactMarkdown from "react-markdown"; +import type { Components } from "react-markdown"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; @@ -8,7 +9,7 @@ const sanitizeSchema = { ...defaultSchema, tagNames: [...(defaultSchema.tagNames || []), "img"], attributes: { - ...(defaultSchema.attributes || {}), + ...defaultSchema.attributes, "*": ["className", "class", "style"], img: [ ...(defaultSchema.attributes?.img || []), @@ -23,12 +24,20 @@ const sanitizeSchema = { a: [...(defaultSchema.attributes?.a || []), "target", "rel"], }, protocols: { - ...(defaultSchema.protocols || {}), + ...defaultSchema.protocols, src: ["http", "https", "data"], href: ["http", "https", "mailto", "tel"], }, }; +const markdownComponents: Components = { + a: ({ node: _node, target, rel, ...anchorProps }) => { + const resolvedTarget = target || "_blank"; + const resolvedRel = resolvedTarget === "_blank" ? "noopener noreferrer" : rel; + return ; + }, +}; + interface MarkdownRendererProps { content: string; className?: string; @@ -37,21 +46,12 @@ interface MarkdownRendererProps { export default function MarkdownRenderer({ content, className, -}: MarkdownRendererProps) { +}: Readonly) { return (
{ - const resolvedTarget = target || "_blank"; - const resolvedRel = - resolvedTarget === "_blank" ? "noopener noreferrer" : rel; - return ( - - ); - }, - }} + components={markdownComponents} > {content} diff --git a/components/ui/card.tsx b/components/ui/card.tsx index 1c06b38..f181a90 100644 --- a/components/ui/card.tsx +++ b/components/ui/card.tsx @@ -29,15 +29,21 @@ const CardHeader = React.forwardRef< )); CardHeader.displayName = "CardHeader"; +interface CardTitleProps extends React.HTMLAttributes { + children: React.ReactNode; +} + const CardTitle = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( + HTMLHeadingElement, + CardTitleProps +>(({ className, children, ...props }, ref) => (

+ > + {children} +

)); CardTitle.displayName = "CardTitle"; diff --git a/components/ui/loader.tsx b/components/ui/loader.tsx index 7086faf..2da2eda 100644 --- a/components/ui/loader.tsx +++ b/components/ui/loader.tsx @@ -4,7 +4,11 @@ interface LoaderProps extends React.HTMLAttributes { size?: "sm" | "md" | "lg"; } -export function Loader({ className, size = "md", ...props }: LoaderProps) { +export function Loader({ + className, + size = "md", + ...props +}: Readonly) { const sizeClasses = { sm: "h-4 w-4 border-2", md: "h-8 w-8 border-2", @@ -32,7 +36,7 @@ interface LoadingProps { text?: string; } -export function Loading({ text = "載入中..." }: LoadingProps) { +export function Loading({ text = "載入中..." }: Readonly) { return (
diff --git a/lib/activityVoterService.ts b/lib/activityVoterService.ts new file mode 100644 index 0000000..5935d1f --- /dev/null +++ b/lib/activityVoterService.ts @@ -0,0 +1,5 @@ +import { ActivityVoter } from "@/lib/models/ActivityVoter"; + +export async function getEligibleVotersCount(activityId: string): Promise { + return ActivityVoter.countDocuments({ activity_id: activityId }); +} diff --git a/lib/statisticsService.ts b/lib/statisticsService.ts index 9f188db..7242c2e 100644 --- a/lib/statisticsService.ts +++ b/lib/statisticsService.ts @@ -2,6 +2,7 @@ import { Activity } from "@/lib/models/Activity"; import { Option } from "@/lib/models/Option"; import { Vote } from "@/lib/models/Vote"; import { Document, Types } from "mongoose"; +import { getEligibleVotersCount } from "@/lib/activityVoterService"; interface OptionStat { option_id: string; @@ -48,7 +49,6 @@ interface ActivityDocument extends Document { name: string; type: string; rule: string; - users: string[]; open_from: Date; open_to: Date; } @@ -85,7 +85,7 @@ export async function calculateActivityStatistics( // Calculate basic statistics const totalVotes = votes.length; - const totalEligibleVoters = activity.users.length; + const totalEligibleVoters = await getEligibleVotersCount(activity_id); const turnoutRate = totalEligibleVoters > 0 ? ((totalVotes / totalEligibleVoters) * 100).toFixed(2)