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
6 changes: 2 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions __tests__/api.activities.id.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
14 changes: 8 additions & 6 deletions app/admin/activities/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -670,9 +670,11 @@ function ActivityDetailPageContent() {
</div>
)}

{option.vice &&
option.vice.map((vice, viceIndex) => (
<div key={viceIndex} className="ml-4 mb-1 text-sm">
{option.vice?.map((vice, viceIndex) => (
<div
key={`${option._id}-${viceIndex}-${vice.name}-${vice.department}-${vice.college}`}
className="ml-4 mb-1 text-sm"
>
<span className="text-muted-foreground">
副選 {viceIndex + 1}:{" "}
</span>
Expand Down
6 changes: 3 additions & 3 deletions app/admin/activities/[id]/verification/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
};

Expand Down Expand Up @@ -135,7 +135,7 @@ function VerificationPageContent() {
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
link.remove();
};

if (loading) {
Expand Down
2 changes: 1 addition & 1 deletion app/admin/activities/_components/ActivityFormFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function ActivityFormFields({
formData,
onChange,
disabled = false,
}: ActivityFormFieldsProps) {
}: Readonly<ActivityFormFieldsProps>) {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
Expand Down
2 changes: 1 addition & 1 deletion app/admin/activities/_components/CandidateFormFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function CandidateFormFields({
onChange,
label,
required = false,
}: CandidateFormFieldsProps) {
}: Readonly<CandidateFormFieldsProps>) {
return (
<div className="space-y-3">
<h4 className="font-semibold">{label}</h4>
Expand Down
27 changes: 14 additions & 13 deletions app/admin/activities/_components/OptionFormSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,29 +37,28 @@ export function OptionFormSection({
editOption,
removeOption,
resetForm,
}: OptionFormSectionProps) {
}: Readonly<OptionFormSectionProps>) {
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 (
<div className="space-y-6">
{/* Current option form */}
<Card className="border-primary/20 bg-primary/5">
<CardHeader>
<CardTitle className="text-lg">
{editingIndex !== null
? `編輯候選人 #${editingIndex + 1}`
: `新增候選人組合 #${options.length + 1}`
}
</CardTitle>
<CardTitle className="text-lg">{cardTitle}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
Expand Down Expand Up @@ -134,7 +133,10 @@ export function OptionFormSection({
已新增的候選人 ({options.length})
</h3>
{options.map((option, index) => (
<Card key={index} className={editingIndex === index ? "border-primary" : ""}>
<Card
key={`option-${index}`}
className={editingIndex === index ? "border-primary" : ""}
>
<CardContent className="flex items-center justify-between py-4">
<div>
<p className="font-medium">
Expand Down Expand Up @@ -176,5 +178,4 @@ export function OptionFormSection({
);
}

// Export the hook for external use
export { useOptionForm };
export { useOptionForm } from "./useOptionForm";
4 changes: 2 additions & 2 deletions app/admin/activities/_components/ViceCandidateSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ export function ViceCandidateSection({
onAddVice,
onRemoveVice,
onViceChange,
}: ViceCandidateSectionProps) {
}: Readonly<ViceCandidateSectionProps>) {
return (
<div className="space-y-3">
{vices.map((vice, index) => (
<div
key={index}
key={`vice-${index}-${vice.name ?? ""}-${vice.department ?? ""}-${vice.college ?? ""}`}
className="relative rounded-lg border border-border p-4 bg-background"
>
<Button
Expand Down
14 changes: 8 additions & 6 deletions app/admin/activities/_components/useOptionForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,16 @@ export function useOptionForm() {
};

const addOrUpdateOption = () => {
if (editingIndex !== null) {
const newOptions = [...options];
newOptions[editingIndex] = currentOption;
setOptions(newOptions);
setEditingIndex(null);
} else {
if (editingIndex === null) {
setOptions([...options, currentOption]);
resetForm();
return;
}

const newOptions = [...options];
newOptions[editingIndex] = currentOption;
setOptions(newOptions);
setEditingIndex(null);
resetForm();
};

Expand Down
58 changes: 30 additions & 28 deletions app/admin/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,35 @@ export default function AdminSettingsPage() {
}
};

const adminListContent = loading ? (
<p className="text-sm text-muted-foreground">載入中...</p>
) : admins.length === 0 ? (
<p className="text-sm text-muted-foreground">尚無資料</p>
) : (
<div className="space-y-2">
{admins.map((admin) => (
<div
key={admin.student_id}
className="flex items-center justify-between rounded-md border p-3"
>
<div>
<p className="font-medium">{admin.student_id}</p>
{admin.name && (
<p className="text-sm text-muted-foreground">{admin.name}</p>
)}
</div>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(admin.student_id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
);

return (
<div className="min-h-screen bg-background">
<Header />
Expand Down Expand Up @@ -162,34 +191,7 @@ export default function AdminSettingsPage() {
</CardHeader>
<Separator />
<CardContent className="pt-6">
{loading ? (
<p className="text-sm text-muted-foreground">載入中...</p>
) : admins.length === 0 ? (
<p className="text-sm text-muted-foreground">尚無資料</p>
) : (
<div className="space-y-2">
{admins.map((admin) => (
<div
key={admin.student_id}
className="flex items-center justify-between rounded-md border p-3"
>
<div>
<p className="font-medium">{admin.student_id}</p>
{admin.name && (
<p className="text-sm text-muted-foreground">{admin.name}</p>
)}
</div>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(admin.student_id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{adminListContent}
</CardContent>
</Card>
</main>
Expand Down
Loading
Loading