Skip to content

feat: update candidate schema html support / add 驗票 - #47

Merged
l7wei merged 5 commits into
mainfrom
copilot/update-candidate-schema-html-support
Apr 20, 2026
Merged

feat: update candidate schema html support / add 驗票#47
l7wei merged 5 commits into
mainfrom
copilot/update-candidate-schema-html-support

Conversation

@l7wei

@l7wei l7wei commented Apr 20, 2026

Copy link
Copy Markdown
Member

No description provided.

Copilot AI review requested due to automatic review settings April 20, 2026 11:37
@l7wei l7wei self-assigned this Apr 20, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request migrates voter and administrator management from static CSV files to a MongoDB-backed system, introducing a ROOT_ADMIN role and activity-specific voter rosters. Key additions include a public vote verification page, candidate descriptions with Markdown support, and administrative APIs for managing users and voter lists. Feedback identifies a scalability concern in the vote verification API regarding the lack of pagination and provides a suggestion to fix indentation in the admin activity dashboard.

Comment on lines 53 to 56
const votes = await Vote.find({ activity_id: id })
.select("token created_at")
.select("token created_at rule choose_one choose_all")
.sort({ created_at: -1 })
.lean();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Fetching all votes for an activity without any limit or pagination can lead to significant performance issues and memory exhaustion if an activity has a large number of votes (e.g., thousands). Consider implementing pagination or at least a reasonable upper limit for this admin verification route.

Comment thread app/admin/activities/[id]/page.tsx Outdated
Comment on lines +208 to +226
candidate: {
name: option.candidate?.name || "",
department: option.candidate?.department || "",
college: option.candidate?.college || "",
avatar_url: option.candidate?.avatar_url || "",
description: option.candidate?.description || "",
experiences: option.candidate?.personal_experiences?.join("\n") || "",
opinions: option.candidate?.political_opinions?.join("\n") || "",
},
vice: (option.vice || []).map((v) => ({
name: v.name || "",
department: v.department || "",
college: v.college || "",
avatar_url: v.avatar_url || "",
description: v.description || "",
experiences: v.personal_experiences?.join("\n") || "",
opinions: v.political_opinions?.join("\n") || "",
})),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The indentation for the candidate and vice properties is inconsistent with the rest of the object structure. It should be aligned with the label property for better readability.

Suggested change
candidate: {
name: option.candidate?.name || "",
department: option.candidate?.department || "",
college: option.candidate?.college || "",
avatar_url: option.candidate?.avatar_url || "",
description: option.candidate?.description || "",
experiences: option.candidate?.personal_experiences?.join("\n") || "",
opinions: option.candidate?.political_opinions?.join("\n") || "",
},
vice: (option.vice || []).map((v) => ({
name: v.name || "",
department: v.department || "",
college: v.college || "",
avatar_url: v.avatar_url || "",
description: v.description || "",
experiences: v.personal_experiences?.join("\n") || "",
opinions: v.political_opinions?.join("\n") || "",
})),
});
candidate: {
name: option.candidate?.name || "",
department: option.candidate?.department || "",
college: option.candidate?.college || "",
avatar_url: option.candidate?.avatar_url || "",
description: option.candidate?.description || "",
experiences: option.candidate?.personal_experiences?.join("\n") || "",
opinions: option.candidate?.political_opinions?.join("\n") || "",
},
vice: (option.vice || []).map((v) => ({
name: v.name || "",
department: v.department || "",
college: v.college || "",
avatar_url: v.avatar_url || "",
description: v.description || "",
experiences: v.personal_experiences?.join("\n") || "",
opinions: v.political_opinions?.join("\n") || "",
})),

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR expands the voting system to support richer candidate content (Markdown/HTML), introduces a public UUID-based vote verification flow (“公開驗票”), and migrates admin/eligible-voter management from CSV files to MongoDB with a ROOT_ADMIN concept.

Changes:

  • Add Markdown/HTML rendering for candidate description/experiences/opinions and loosen CSP to allow external images.
  • Add public verification UI + API (/verify, /api/verify/:uuid) and enrich admin verification/vote listing with human-readable selections.
  • Move admin list and per-activity voter rosters into MongoDB (new admins + activity_voters models/APIs, new admin settings page).

Reviewed changes

Copilot reviewed 33 out of 34 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
types/index.ts Adds new fields/types for root admin + admin/voter collections + candidate description.
proxy.ts Marks verify routes as public and broadens CSP img-src for external images.
package.json Adds react-markdown + rehype plugins for Markdown/HTML rendering.
package-lock.json Locks new Markdown-related dependencies.
lib/voterList.ts Removes CSV-based voter list helper.
lib/models/Option.ts Extends candidate schema with description.
lib/models/Admin.ts New MongoDB model for administrators.
lib/models/ActivityVoter.ts New MongoDB model for per-activity eligible voters (with indexes).
lib/models/Activity.ts Adds eligible voter count field to activity schema.
lib/auth.ts Reworks admin checks to use ROOT_ADMIN + admins collection.
hooks/useUser.ts Surfaces isRootAdmin on the client user model.
components/MarkdownRenderer.tsx New client component for Markdown + sanitized raw HTML rendering.
components/Header.tsx Adds “公開驗票” nav item + root-admin-only settings link.
app/vote/[id]/page.tsx Renders candidate content via MarkdownRenderer (incl. new description section).
app/verify/page.tsx New public UI to verify a vote by UUID.
app/api/votes/route.ts Eligibility now checks ActivityVoter; GET now enriches votes with readable selections.
app/api/verify/[token]/route.ts New public verification endpoint that returns selections for a UUID token.
app/api/auth/check/route.ts Includes isRootAdmin in auth check response.
app/api/admins/route.ts New root-admin-only CRUD API for admins.
app/api/activities/route.ts Initializes eligibleVotersCount on activity creation.
app/api/activities/[id]/voters/route.ts New admin endpoint to upload/replace per-activity eligible voter roster via CSV.
app/api/activities/[id]/verification/route.ts Enriches verification output with selections + activity name.
app/admin/settings/page.tsx New root-admin-only UI to manage admins.
app/admin/page.tsx Adds root-admin shortcut to settings.
app/admin/activities/_components/utils.ts Sends candidate description in payload.
app/admin/activities/_components/types.ts Adds description to candidate form type.
app/admin/activities/_components/formHelpers.ts Initializes empty candidate description.
app/admin/activities/_components/CandidateFormFields.tsx Adds description textarea + updates placeholders for Markdown/HTML support.
app/admin/activities/[id]/verification/page.tsx Displays + exports vote selections in verification UI/CSV.
app/admin/activities/[id]/page.tsx Adds per-activity voter roster upload UI + shows eligible voter count.
tests/auth.integration.test.ts Updates mocks to include isRootAdmin.
tests/api.votes.test.ts Updates vote route tests for DB-based eligibility + enriched GET response.
README.md Updates env/config docs for ROOT_ADMIN and MongoDB-based rosters/admins; documents public verify API.
.env.example Adds ROOT_ADMIN and clarifies environment values.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/models/Activity.ts Outdated
Comment on lines +21 to +26
eligibleVotersCount: {
type: Number,
required: true,
default: 0,
min: 0,
},

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

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

eligibleVotersCount introduces a camelCase persisted field in Activity while the rest of the schema uses snake_case (open_from, created_at, etc.). This inconsistency makes querying/serialization harder and increases the chance of subtle bugs. Consider renaming the stored field to eligible_voters_count (and updating types/routes/UI accordingly), or adding a schema alias/virtual so both names work consistently.

Copilot uses AI. Check for mistakes.
Comment thread types/index.ts
Comment on lines 4 to 11
export interface IActivity {
_id: Types.ObjectId | string;
name: string;
type: string; // Kept for backward compatibility
subtitle?: string;
description?: string; // New field for activity description
eligibleVotersCount: number;
rule: "choose_all" | "choose_one";

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

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

IActivity now requires eligibleVotersCount, but persisted activity fields elsewhere are snake_case (e.g. open_from, created_at). Aligning the property name with the DB field naming convention will reduce confusion across API payloads and Mongoose queries (see comment in lib/models/Activity.ts).

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +40
return createSuccessResponse({
activity_id: id,
activity_name: "",
total_votes: 0,
voted_tokens: [],
});

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

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

When the activity does not exist, this endpoint currently returns a success: true response with empty data. That makes it hard for admin clients to distinguish "no votes yet" vs "invalid activity id" and is inconsistent with other activity endpoints. Consider returning a 404 error (e.g. ACTIVITY_NOT_FOUND) instead.

Suggested change
return createSuccessResponse({
activity_id: id,
activity_name: "",
total_votes: 0,
voted_tokens: [],
});
return NextResponse.json(
{
success: false,
error: "ACTIVITY_NOT_FOUND",
message: "Activity not found",
},
{ status: 404 },
);

Copilot uses AI. Check for mistakes.
Comment thread app/api/verify/[token]/route.ts Outdated

const { token } = await params;
if (!token?.trim()) {
return createErrorResponse("UUID is required", 400);

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

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

The 400 error message is English ("UUID is required") while other responses in this handler/UI are Chinese. For a consistent UX, consider using the same locale for all errors returned by this endpoint.

Suggested change
return createErrorResponse("UUID is required", 400);
return createErrorResponse("UUID 為必填欄位", 400);

Copilot uses AI. Check for mistakes.
Comment thread app/vote/[id]/page.tsx Outdated
Comment on lines 259 to 267
<ul className="space-y-1">
{candidate.personal_experiences.map(
(exp: string, idx: number) => (
<li key={idx} className="flex items-start text-sm">
<span className="mr-2 text-primary">•</span>
<span>{exp}</span>
<li key={idx} className="text-sm">
<MarkdownRenderer
content={exp}
className="break-words"
/>
</li>

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

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

This change removes the explicit bullet marker for experiences/opinions, so items will render without bullets unless the content itself contains Markdown list syntax. If the intent is to keep a list UI, consider restoring bullets via Tailwind list styles (e.g. list-disc list-inside) or reintroducing the marker.

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +66
// GET /api/verify/[token] - Public UUID verification
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ token: string }> },
) {
try {
await connectDB();

const { token } = await params;
if (!token?.trim()) {
return createErrorResponse("UUID is required", 400);
}

const vote = await Vote.findOne({ token: token.trim() }).lean();
if (!vote) {
return createErrorResponse("找不到此 UUID 的投票記錄", 404);
}

const activity = await Activity.findById(vote.activity_id)
.select("name")
.lean();
const options = await Option.find({ activity_id: vote.activity_id })
.select("_id label candidate.name")
.lean();

const optionMap = new Map<string, string>();
options.forEach((option) => {
optionMap.set(
option._id.toString(),
option.label || option.candidate?.name || option._id.toString(),
);
});

const selections =
vote.rule === "choose_one"
? [
optionMap.get(vote.choose_one?.toString() || "") ||
vote.choose_one?.toString() ||
"",
].filter(Boolean)
: (vote.choose_all || []).map((choice) => {
const optionName =
optionMap.get(choice.option_id.toString()) ||
choice.option_id.toString();
return `${optionName}(${choice.remark})`;
});

return createSuccessResponse({
uuid: vote.token,
activity_id: vote.activity_id,
activity_name: activity?.name || "未知活動",
voted_at: vote.created_at,
rule: vote.rule,
selections,
});

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

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

This new public verification endpoint has several branching behaviors (400 for missing UUID, 404 for unknown UUID, and success mapping of selections). The repo already has Jest coverage for other API routes (e.g. /api/votes), so adding route tests here would help prevent regressions and ensure the anonymized selection mapping stays correct.

Copilot uses AI. Check for mistakes.
Comment thread app/api/admins/route.ts
Comment on lines +13 to +132
async function requireRootAdmin(request: NextRequest) {
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) return authResult;
if (!isRootAdmin(authResult.student_id)) {
return createErrorResponse(API_CONSTANTS.ERRORS.ADMIN_REQUIRED, 403);
}
return authResult;
}

// GET /api/admins - list admins (ROOT_ADMIN only)
export async function GET(request: NextRequest) {
try {
const authResult = await requireRootAdmin(request);
if (authResult instanceof NextResponse) {
return authResult;
}

await connectDB();
const rootAdmin = getRootAdminStudentId();
const admins = await Admin.find().sort({ created_at: -1 }).lean();

return createSuccessResponse({
root_admin: rootAdmin,
admins,
});
} catch (error: unknown) {
return createInternalErrorResponse(
error,
"Failed to get admins",
"Get admins error",
);
}
}

// POST /api/admins - create admin (ROOT_ADMIN only)
export async function POST(request: NextRequest) {
try {
const authResult = await requireRootAdmin(request);
if (authResult instanceof NextResponse) {
return authResult;
}

await connectDB();
const body = await request.json();
const studentId = body.student_id?.trim();
const name = body.name?.trim() || undefined;

if (!studentId) {
return createErrorResponse(
`${API_CONSTANTS.ERRORS.MISSING_FIELD}: student_id`,
);
}

if (isRootAdmin(studentId)) {
return createErrorResponse("ROOT_ADMIN is managed by environment variable");
}

const admin = await Admin.findOneAndUpdate(
{ student_id: studentId },
{
$set: {
student_id: studentId,
name,
updated_at: new Date(),
},
$setOnInsert: {
created_at: new Date(),
},
},
{
upsert: true,
new: true,
runValidators: true,
},
);

return createSuccessResponse(admin, 201);
} catch (error: unknown) {
return createInternalErrorResponse(
error,
"Failed to create admin",
"Create admin error",
);
}
}

// DELETE /api/admins?student_id=xxx - delete admin (ROOT_ADMIN only)
export async function DELETE(request: NextRequest) {
try {
const authResult = await requireRootAdmin(request);
if (authResult instanceof NextResponse) {
return authResult;
}

await connectDB();
const studentId = request.nextUrl.searchParams.get("student_id")?.trim();
if (!studentId) {
return createErrorResponse(
`${API_CONSTANTS.ERRORS.MISSING_FIELD}: student_id`,
);
}

if (isRootAdmin(studentId)) {
return createErrorResponse("Cannot delete ROOT_ADMIN", 400);
}

const deleted = await Admin.findOneAndDelete({ student_id: studentId });
if (!deleted) {
return createErrorResponse("Admin not found", 404);
}

return createSuccessResponse({ message: "Admin deleted successfully" });
} catch (error: unknown) {
return createInternalErrorResponse(
error,
"Failed to delete admin",
"Delete admin error",
);
}
}

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

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

This new root-admin management API (GET/POST/DELETE) introduces permission checks and upsert/delete behavior. Given the repo’s existing Jest coverage for auth/API routes, it would be good to add tests covering: root-only access enforcement, upsert behavior, and 404 on delete of missing admin.

Copilot uses AI. Check for mistakes.
Comment thread components/MarkdownRenderer.tsx Outdated
Comment on lines +45 to +48
a: (props) => {
const target = props.target || "_blank";
const rel = target === "_blank" ? "noopener noreferrer" : props.rel;
return <a {...props} target={target} rel={rel} />;

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

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

In the custom a renderer, props from react-markdown includes non-DOM fields (e.g. node). Spreading them onto <a> can produce React "unknown prop" warnings and leak unintended attributes into the DOM. Destructure out non-DOM props (at least node) before spreading, and only pass valid anchor props through.

Suggested change
a: (props) => {
const target = props.target || "_blank";
const rel = target === "_blank" ? "noopener noreferrer" : props.rel;
return <a {...props} target={target} rel={rel} />;
a: ({ node, target, rel, ...anchorProps }) => {
const resolvedTarget = target || "_blank";
const resolvedRel =
resolvedTarget === "_blank" ? "noopener noreferrer" : rel;
return <a {...anchorProps} target={resolvedTarget} rel={resolvedRel} />;

Copilot uses AI. Check for mistakes.
Agent-Logs-Url: https://github.com/NTHU-SA/Voting-System/sessions/e5545cfe-2b10-4ca0-b757-6e211bda05ac

Co-authored-by: l7wei <87221670+l7wei@users.noreply.github.com>
@sonarqubecloud

Copy link
Copy Markdown

@l7wei
l7wei merged commit 5e5cbf8 into main Apr 20, 2026
2 checks passed
@l7wei
l7wei deleted the copilot/update-candidate-schema-html-support branch April 20, 2026 18:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants