Skip to content

feat: revamp bank soal per jurusan, add angkatan filter checkboxes - #87

Closed
Jedijedai wants to merge 31 commits into
mannnrachman:mainfrom
Hzkun001:feature/bank-soal-jurusan
Closed

Jedijedai wants to merge 31 commits into
mannnrachman:mainfrom
Hzkun001:feature/bank-soal-jurusan

Conversation

@Jedijedai

@Jedijedai Jedijedai commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Migrate Topik to use unitId instead of modulId. Add checkbox filter for angkatan in Ujian Editor.

Summary by CodeRabbit

  • New Features
    • Added scientific calculator and medical normal-value reference tools for exams.
    • Added public exams, cohort-based access restrictions, and participant cohort information.
    • Added temporary answer saving, session timers, audit logging, and session recovery.
    • Reorganized question-bank management around academic units and topics.
  • Changes
    • Removed course and module management.
    • Updated analytics, exam, import, and participant pages for the new structure.
    • Improved exam error messages and participant exam presentation.

Hzkun001 and others added 23 commits July 31, 2026 11:36
…esiDicari loading state with async Redis recovery
@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bc7a628d-c847-4e70-9142-51e0e4540488

📥 Commits

Reviewing files that changed from the base of the PR and between 249569c and ba5a084.

📒 Files selected for processing (1)
  • src/routes/_authenticated/admin.ujian.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/routes/_authenticated/admin.ujian.tsx

📝 Walkthrough

Walkthrough

The change replaces module and course entities with unit-owned topics, adds cohort-based exam access, introduces calculator and medical reference tools, and adds Redis-backed session storage. Academic routes, backups, seeds, authorization, repositories, and participant management are updated.

Changes

Academic model migration

Layer / File(s) Summary
Data contracts and persistence
prisma/schema.prisma, src/lib/cbt/*, src/lib/server/repos/*, src/lib/server/backup/*, src/lib/server/db/seed-shared.mjs
Module and course records are removed. Topics now belong to academic units. Users store optional cohorts. Exams store cohort IDs, public status, and tool permissions.
Access and server mutations
src/lib/cbt/access.ts, src/lib/server/db/auth.ts, src/lib/server/modul/functions.ts, src/lib/server/akademik/functions.ts
Topic and exam access use topic, unit, group, cohort, and public-exam rules. Legacy module and course mutation endpoints are removed.
Academic route migration
src/routes/_authenticated/admin.*, src/routeTree.gen.ts
Academic management, imports, topic navigation, roles, analytics, evaluation, dashboards, and route registration use units, topics, and exams.

Exam tools and sessions

Layer / File(s) Summary
Exam tools and clinical seed data
src/routes/_authenticated/admin.ujian*, src/routes/_authenticated/admin.peserta.index.tsx, scratch/seed-clinical.ts
Exam administration supports public and cohort-restricted exams, calculator and normal-value settings, and cohort fields for participants. The seed script creates clinical questions and an exam.
Reference-tool components
src/components/cbt/CheatSheets.tsx
The CBT interface adds medical normal-value tables and a scientific calculator with sanitized expression evaluation.
Redis session flow
package.json, src/lib/server/redis/*
Validated server functions delegate temporary-answer, timer, cleanup, and audit operations to RedisService. The service uses Redis with an in-memory fallback.
Participant exam entry updates
src/routes/_authenticated/peserta.ujian.$id.index.tsx
Course metadata is removed. Error messages are bounded. Visual accents use emerald and teal.

Estimated code review effort: 5 (Critical) | ~90+ minutes

Possibly related PRs

Poem

A rabbit checks the topics in a row,
Cohorts guide the exams where they go.
Redis keeps each answer near,
A calculator hops without fear.
Normal values bloom on screen—
CBT grows tidy, fast, and green.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: revising the question bank by academic unit and adding cohort filter checkboxes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 15

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/routes/_authenticated/admin.users.roles.tsx (1)

35-43: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Restrict /admin/users/roles to super_admin and use units for the new entity.

Loader calls use auth-only _authenticated without a role guard, while getUnitAcademikList() returns [] unless caller.role === "super_admin". Any non-super_admin who reaches /admin/users/roles gets an empty unit list, and the topic group for each manager never renders. Add the role guard before the route, then also use getUnitAkademikList() for getUsersList(). Rename the loader binding and UI labels from moduls to units because the section now maps topics by unitAkademik, not modul.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/_authenticated/admin.users.roles.tsx` around lines 35 - 43,
Restrict the `/admin/users/roles` route with a `super_admin` role guard before
its loader runs. Update `Loader` to obtain and pass the unit list from
`getUnitAkademikList()` into `getUsersList()` as required, rename the `moduls`
binding and all related UI labels/references to `units`, and keep topic grouping
based on each manager’s `unitAkademik`.
🟡 Minor comments (7)
src/routes/_authenticated/admin.peserta.index.tsx-241-241 (1)

241-241: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update the empty-state column span.

The table now has seven columns, but the empty-state cell at Line 292 still uses colSpan={6}. Set it to 7 so the empty state spans the full table.

Proposed fix
-<TableCell colSpan={6} className="h-48 text-center">
+<TableCell colSpan={7} className="h-48 text-center">

Also applies to: 259-259

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/_authenticated/admin.peserta.index.tsx` at line 241, Update the
empty-state table cell’s colSpan from 6 to 7 in the table containing the “Tahun
Angkatan” TableHead, including the corresponding occurrence noted near the other
header, so the empty state spans all seven columns.
src/lib/server/redis/service.ts-146-159 (1)

146-159: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Clamp the computed TTL before calling SET ... EX.

durationMinutes reaches this method from the request body of setSessionTimerServer without validation. Redis rejects an expire value that is zero or negative with an error. A durationMinutes of -60 or lower produces durationMinutes * 60 + 3600 <= 0, and the SET throws. The catch block is empty, so the call then writes the same non-positive TTL into setFallback on Line 158, where Date.now() + ttl * 1000 yields an already-expired entry. The timer is lost with no log line.

Clamp the TTL and reject a non-finite durationMinutes.

🐛 Proposed fix
   async setSessionTimer(sesiId: string, endsAt: number, durationMinutes: number): Promise<void> {
     const key = REDIS_KEYS.sessionTimer(sesiId);
+    const safeMinutes = Number.isFinite(durationMinutes)
+      ? Math.min(Math.max(durationMinutes, 1), 24 * 60)
+      : 60;
+    const ttlSeconds = safeMinutes * 60 + 3600;
     const data = JSON.stringify({ endsAt, durationMinutes, startedAt: Date.now() });
 
     if (this.isConnected && this.redis) {
       try {
-        await this.redis.set(key, data, "EX", durationMinutes * 60 + 3600);
+        await this.redis.set(key, data, "EX", ttlSeconds);
         return;
-      } catch {
-        // fallback
+      } catch (err) {
+        console.warn("[Redis] Failed to set session timer, using fallback:", err);
       }
     }
-    this.setFallback(key, data, durationMinutes * 60 + 3600);
+    this.setFallback(key, data, ttlSeconds);
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/server/redis/service.ts` around lines 146 - 159, Update
setSessionTimer to reject non-finite durationMinutes and clamp the computed TTL
to a positive minimum before using it with redis.set or setFallback. Reuse the
same validated TTL for both storage paths so invalid durations cannot produce
Redis errors or already-expired fallback entries.
src/routes/_authenticated/admin.index.tsx-80-85 (1)

80-85: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use consistent Indonesian scale abbreviations.

The formatter mixes locales. rb is the Indonesian abbreviation for ribu (thousand), but M is the English abbreviation for million. In Indonesian shorthand, M reads as miliar (billion), and million is jt. The decimal separator is also a period instead of the Indonesian comma.

🌐 Proposed fix for locale-consistent formatting
   const formatNumber = (num: number) => {
-    if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
-    if (num >= 1000) return `${(num / 1000).toFixed(1)}rb`;
-    return num.toString();
+    if (num >= 1_000_000) return `${(num / 1_000_000).toLocaleString("id-ID", { maximumFractionDigits: 1 })} jt`;
+    if (num >= 1_000) return `${(num / 1_000).toLocaleString("id-ID", { maximumFractionDigits: 1 })} rb`;
+    return num.toLocaleString("id-ID");
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/_authenticated/admin.index.tsx` around lines 80 - 85, Update
formatNumber to use Indonesian abbreviations consistently: represent millions
with “jt” and thousands with “rb”, and replace the decimal point with an
Indonesian comma in formatted values. Preserve the existing one-decimal rounding
and plain numeric output for values below one thousand.
src/routes/_authenticated/admin.tsx-189-195 (1)

189-195: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The rule-key fallback highlights the wrong item when two items share one rule key.

/admin/peserta and /admin/peserta/online both resolve to the peserta rule key. For a pathname such as /admin/peserta/kartu, the exact /admin/peserta item returns false at line 190, then the fallback at lines 192-194 marks "Pantau Ujian Live" as active. The dashboard "Kartu Peserta" shortcut links to that path, so the state is reachable.

Restrict the fallback to items whose own path is a prefix of the pathname, or to the single longest-matching item in the group.

🐛 Proposed fix for the active-item fallback
 function isNavItemActive(item: NavItem, pathname: string) {
   if (item.exact) return pathname === item.to;
   if (pathname === item.to || pathname.startsWith(`${item.to}/`)) return true;
+  // Only fall back to rule-key matching when the item is not a deeper sibling
+  // under a shared rule (e.g. /admin/peserta vs /admin/peserta/online).
+  if (!pathname.startsWith(`${item.to.split("/").slice(0, 3).join("/")}/`)) return false;
   const rule = resolveAdminRouteRule(pathname);
   const itemRule = resolveAdminRouteRule(item.to);
   return !!(rule && itemRule && itemRule.key === rule.key);
 }

Prefer selecting the longest matching item.to across the group if that fits the navigation model better.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/_authenticated/admin.tsx` around lines 189 - 195, Update
isNavItemActive so the rule-key fallback cannot activate an item whose item.to
is not a path prefix of pathname; require the item path to match as an exact
path or directory prefix before applying the shared rule-key comparison.
Preserve exact-item handling and prevent shorter same-rule entries such as
/admin/peserta from competing with more specific paths.
src/routes/_authenticated/admin.analitik.index.tsx-142-144 (1)

142-144: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Remove the duplicate exam name.

Line 139 already renders u.nama. Lines 142-144 render the same value again. Remove this block or replace it with distinct metadata.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/_authenticated/admin.analitik.index.tsx` around lines 142 - 144,
Remove the duplicate u.nama rendering from the nearby h3 block in the relevant
component, since the exam name is already rendered earlier. Leave the existing
first rendering intact and only retain this block if it is replaced with
distinct metadata.
src/lib/server/backup/functions.ts-77-77 (1)

77-77: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Remove the invalid type assertion for angkatanIds.

stringifyJson() returns string, while the Ujian Prisma field expects String; the existing backup import other string-array fields the same way.

Proposed fix
-						angkatanIds: stringifyJson(item.angkatanIds) as unknown as string[],
+						angkatanIds: stringifyJson(item.angkatanIds),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/server/backup/functions.ts` at line 77, Update the angkatanIds
assignment in the backup Ujian import to remove the unknown/string[] type
assertion and preserve the string returned by stringifyJson(item.angkatanIds),
matching the handling of the other string-array fields.
src/routes/_authenticated/admin.topik.$id.soal.tsx-30-30 (1)

30-30: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not block question management when the unit cannot be resolved.

Line 43 treats a missing unit as a missing topic and reports "Topik tidak ditemukan". A topic whose unitId did not migrate then becomes unmanageable, and the message names the wrong cause. Line 339 already tolerates a missing unit in the move dialog. Gate only on topik, and render the breadcrumb conditionally.

🐛 Proposed fix
-  if (!topik || !unit) return (
+  if (!topik) return (

Then guard the breadcrumb:

-              <Link to="/admin/modul/$id/topik" params={{ id: unit.id }} className="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors">
-                {unit.nama}
-              </Link>
+              {unit && (
+                <Link to="/admin/modul/$id/topik" params={{ id: unit.id }} className="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors">
+                  {unit.nama}
+                </Link>
+              )}

Also applies to: 43-48

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/_authenticated/admin.topik`.$id.soal.tsx at line 30, Update the
question-management route around the `unit` lookup and the topic-not-found guard
so access is gated only by `topik`; a missing `unit` must not trigger the “Topik
tidak ditemukan” response. Render the breadcrumb conditionally when `unit` is
available, while preserving the existing missing-topic handling and downstream
behavior.
🧹 Nitpick comments (5)
src/components/cbt/CheatSheets.tsx (1)

207-218: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove or implement the non-functional controls.

Inv, Keypad, and History on Lines 209-217 are div elements with cursor-pointer and no handler. The Ans button on Line 259 has an empty onClick. Each element looks interactive and does nothing. During an exam this costs the participant time.

The div elements are also not reachable by keyboard and have no role, so keyboard users get no feedback at all.

Remove these controls, or implement them and convert the div elements to button elements. handleDelete on Line 150 is also defined but never bound to a control.

Also applies to: 259-259

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/cbt/CheatSheets.tsx` around lines 207 - 218, Remove the
non-functional Inv, Keypad, History, and Ans controls, or implement their
interactions fully. If retained, convert the interactive divs to accessible
button elements with keyboard support and meaningful handlers, and bind the
existing handleDelete function to the appropriate control instead of leaving it
unused or exposing empty onClick behavior.
src/routes/_authenticated/admin.index.tsx (1)

330-350: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the telemetry values instead of hardcoding them.

SQLite Local, Prisma Client, and v1.2.0 are static strings in the view. The version label will drift from package.json after the next release, and the database label will be wrong if the deployment changes provider. Source these values from build-time constants or a server function.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/_authenticated/admin.index.tsx` around lines 330 - 350, Update the
System Environment Telemetry block in the admin page to use existing build-time
constants or a server-provided configuration source for the database provider,
ORM engine, and application version instead of hardcoded “SQLite Local”, “Prisma
Client”, and “v1.2.0” values. Preserve the current labels and styling while
ensuring the displayed values reflect the deployed configuration and package
version.
src/routes/_authenticated/admin.tsx (1)

339-342: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Use the generated route-path union for admin navigation links.

NavItem["to"], pendingTasks[].route, and ShortcutCard.href currently model valid admin paths as plain string, so renamed or deleted links can pass type checks and still navigate to a nonexistent route. Use FileRouteTypes["fullPaths"] or a derived admin route union for these fields so link failures are caught at build time.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/_authenticated/admin.tsx` around lines 339 - 342, The admin
navigation types currently allow arbitrary strings, so invalid or removed routes
bypass type checking. In src/routes/_authenticated/admin.tsx at lines 339-342
and src/routes/_authenticated/admin.index.tsx at lines 283 and 416-419, update
NavItem["to"], pendingTasks[].route, and ShortcutCard.href to use
FileRouteTypes["fullPaths"] or a derived admin route union, and remove the cast
that bypasses Link route validation.
src/lib/server/modul/functions.ts (1)

60-72: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Validate the topik payload with TopikSchema instead of casting to any.

payload is z.any(), and the casts remove the last type check. TopikSchema now requires unitId, so a request that still sends modulId writes a topic with no unit and produces orphan rows in the new model. Parse the payload before the transaction.

♻️ Proposed validation
+			const topikList =
+				action === "bulkSet"
+					? TopikSchema.array().parse(payload)
+					: null;
+			const topikItem =
+				action === "upsert" ? TopikSchema.parse(payload) : null;
+
 			await prisma.$transaction(async (tx) => {
 				if (action === "remove")
 					await tx.topik.delete({ where: { id: String(payload.id) } });
 				else if (action === "bulkSet") {
 					await tx.topik.deleteMany();
-					await tx.topik.createMany({ data: payload as any });
-				} else
-					await tx.topik.upsert({
-						where: { id: payload.id },
-						update: payload as any,
-						create: payload as any,
-					});
+					await tx.topik.createMany({ data: topikList! });
+				} else
+					await tx.topik.upsert({
+						where: { id: topikItem!.id },
+						update: topikItem!,
+						create: topikItem!,
+					});
 			});

Import TopikSchema from @/lib/cbt/types in addition to the existing type imports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/server/modul/functions.ts` around lines 60 - 72, Validate the topik
payload with TopikSchema before entering the prisma.$transaction callback,
replacing the any-based casts used by the remove, bulkSet, and upsert branches.
Import TopikSchema from `@/lib/cbt/types` and use the parsed payload so unitId is
required and legacy modulId-only requests cannot create orphan topics.
src/routes/_authenticated/admin.modul.tsx (1)

35-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the unreachable branch in the shown filter.

canEdit is isUnrestricted(user), and allowedTopikIdSet returns null only when isUnrestricted(user) is true. Line 40 therefore already handles every case where allowedSet is null, and the return true at line 45 is unreachable. The topikRepo.all() call also runs once per unit; hoist it out of the filter.

♻️ Proposed simplification
+  const allTopiks = topikRepo.all();
   const shown = units.filter(u => {
     const isQueryMatch = !query || u.nama.toLowerCase().includes(query.toLowerCase());
     if (!isQueryMatch) return false;
-    
-    // Check if operator has access to any topik in this unit (or if unrestricted)
-    if (canEdit) return true;
-    const unitTopiks = topikRepo.all().filter((t) => t.unitId === u.id);
-    if (allowedSet) {
-      return unitTopiks.some(t => allowedSet.has(t.id));
-    }
-    return true;
+    // Unrestricted operators see every unit.
+    if (!allowedSet) return true;
+    return allTopiks.some((t) => t.unitId === u.id && allowedSet.has(t.id));
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/_authenticated/admin.modul.tsx` around lines 35 - 46, Update the
shown filter to remove the unreachable fallback return after the allowedSet
check, relying on canEdit for unrestricted users and allowedSet membership
otherwise. Hoist the topikRepo.all() result outside the units.filter callback,
then reuse it when determining each unit’s accessible topics.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scratch/seed-clinical.ts`:
- Around line 8-21: Update the clinical seed setup to fetch the required
identifier from the UnitAkademik model instead of calling
prisma.modul.findFirst(), and rename the local identifier accordingly. In the
prisma.topik.create data, replace modulId with the Topik model’s unitId field
and pass the UnitAkademik identifier, preserving the existing missing-record
validation.

In `@src/components/cbt/CheatSheets.tsx`:
- Around line 83-131: Update evaluateMathExpression to allow and parse the ^
operator with appropriate power precedence, and reject any expression where pos
does not equal tokens.length after parseExpr completes. Resolve the keypad
conflict by changing the mod button’s behavior or label so it performs modulo
rather than the existing percent expansion, while preserving percent parsing for
percentage inputs.

In `@src/lib/cbt/access.ts`:
- Around line 87-101: Align visibleUjians with ujianTouchesAllowed by applying
the same every-topic authorization rule when filtering restricted users,
replacing the current some-based predicate while preserving unrestricted-user
behavior.

In `@src/lib/server/redis/service.ts`:
- Around line 16-31: Prevent the module-level Redis fallback cache from growing
indefinitely: in the service constructor, start a periodic sweep that removes
expired entries from memoryFallback and call unref() on the interval timer. Also
update clearSessionBuffer to delete the audit log key alongside the existing
answer and timer keys, while preserving current fallback expiration behavior in
setFallback and getFallback.
- Around line 71-104: Update saveTempAnswer to reduce per-save Redis work:
record only soalId in the ANSWER_UPDATED audit entry instead of spreading
jawabanData, and pipeline the audit list append/expiry together with the answer
hash write/expiry so they execute in a single Redis round trip. Preserve the
existing fallback-memory behavior when Redis is unavailable or the pipelined
operation fails.
- Around line 33-59: Update the Redis client setup in the constructor so
retryStrategy always returns a capped numeric backoff instead of null, allowing
reconnection after transient failures. Set isConnected from the Redis ready
event rather than connect, and add an end-event handler that logs when
reconnection has finally stopped while preserving the disconnected state.

In `@src/routes/_authenticated/admin.modul.import.tsx`:
- Around line 31-41: Both import routes incorrectly use unit-filtered topics for
the empty-state guard, preventing switching units when the first unit has no
topics. In src/routes/_authenticated/admin.modul.import.tsx lines 31-41, base
the guard on visibleTopiks(user).length and disable import buttons when topikId
is empty. In src/routes/_authenticated/admin.modul.import-word.tsx lines 89-96,
use the same all-visible-topics guard and preserve the existing if (!topikId)
check in loadFile for the selected-unit validation.

In `@src/routes/_authenticated/admin.modul.tsx`:
- Line 31: Update the unit list initialization in the component containing the
useState call to read unitAkademikRepo.all() during each render instead of
storing the result in state. Keep the existing units consumers unchanged so the
list reflects repository hydration consistently with the live topikRepo and
soalRepo reads.

In `@src/routes/_authenticated/admin.peserta.index.tsx`:
- Line 413: Update upsertUserServer to persist angkatan in both Prisma update
and create payloads so PesertaDialog.save stores the cohort year. In the Excel
import path, preserve existingUser?.angkatan when the import does not provide a
value, or explicitly define blank-versus-omitted behavior so re-importing cannot
unintentionally clear an existing cohort.

In `@src/routes/_authenticated/admin.tsx`:
- Around line 328-334: Add inert={!isOpen} to the nav group panel div identified
by its nav-group-${group.id}-panel id, so collapsed panels and their nested
links are removed from keyboard focus and assistive technology while closed.
- Around line 116-119: Update ADMIN_ROUTE_RULES to add an analitik entry
matching the existing /admin/analitik navigation and route-tree path, so
resolveAdminRouteRule("/admin/analitik") resolves correctly. If route access is
keyed by NavKey or role configuration, also add analitik to those definitions to
preserve independent analytics access.

In `@src/routes/_authenticated/admin.ujian`.$id.tsx:
- Around line 452-506: Update the isUmum switch handler in the Ujian form to
clear both groupIds and angkatanIds when enabling the public-exam mode,
preventing stale restrictions from blocking mahasiswa access. When disabling
isUmum, preserve the existing restriction arrays rather than resetting them.

In `@src/routes/_authenticated/admin.ujian.tsx`:
- Around line 95-101: Update submitAdd around ujianRepo.flush() to handle both a
rejected promise and an { ok: false } result: show the failure toast, keep the
list view open, remove the optimistic entry through setList, and reset isAdding.
Only append the entry, show the success toast, close the dialog, and navigate to
the edit route after a successful flush.

In `@src/routes/_authenticated/peserta.ujian`.$id.kerjakan.tsx:
- Around line 324-327: Update the Redis buffering flow around updateJawaban and
flushRedisSave: assign pendingRedisPayload.current before arming redisSaveTimer,
and have the timer invoke flushRedisSave. In handleNavigateIdx, flush the
pending Redis write instead of only clearing its timer; in submit, invoke
flushRedisSave before sesiRepo.flush() and ensure redisSaveTimer is cleared so
no delayed save recreates the submitted session buffer.
- Around line 211-217: Split the server timer synchronization from the main
timer/interval effect: place the setSessionTimerServer call in a dedicated
useEffect keyed only by the session identifier and endsAt (plus the required
session/test values), so answer updates do not trigger new POSTs. Narrow the
existing interval effect’s dependencies to stable values such as sesi?.id and
the relevant timer inputs, preserving its early-return behavior and preventing
interval recreation on every updateJawaban change.

---

Outside diff comments:
In `@src/routes/_authenticated/admin.users.roles.tsx`:
- Around line 35-43: Restrict the `/admin/users/roles` route with a
`super_admin` role guard before its loader runs. Update `Loader` to obtain and
pass the unit list from `getUnitAkademikList()` into `getUsersList()` as
required, rename the `moduls` binding and all related UI labels/references to
`units`, and keep topic grouping based on each manager’s `unitAkademik`.

---

Minor comments:
In `@src/lib/server/backup/functions.ts`:
- Line 77: Update the angkatanIds assignment in the backup Ujian import to
remove the unknown/string[] type assertion and preserve the string returned by
stringifyJson(item.angkatanIds), matching the handling of the other string-array
fields.

In `@src/lib/server/redis/service.ts`:
- Around line 146-159: Update setSessionTimer to reject non-finite
durationMinutes and clamp the computed TTL to a positive minimum before using it
with redis.set or setFallback. Reuse the same validated TTL for both storage
paths so invalid durations cannot produce Redis errors or already-expired
fallback entries.

In `@src/routes/_authenticated/admin.analitik.index.tsx`:
- Around line 142-144: Remove the duplicate u.nama rendering from the nearby h3
block in the relevant component, since the exam name is already rendered
earlier. Leave the existing first rendering intact and only retain this block if
it is replaced with distinct metadata.

In `@src/routes/_authenticated/admin.index.tsx`:
- Around line 80-85: Update formatNumber to use Indonesian abbreviations
consistently: represent millions with “jt” and thousands with “rb”, and replace
the decimal point with an Indonesian comma in formatted values. Preserve the
existing one-decimal rounding and plain numeric output for values below one
thousand.

In `@src/routes/_authenticated/admin.peserta.index.tsx`:
- Line 241: Update the empty-state table cell’s colSpan from 6 to 7 in the table
containing the “Tahun Angkatan” TableHead, including the corresponding
occurrence noted near the other header, so the empty state spans all seven
columns.

In `@src/routes/_authenticated/admin.topik`.$id.soal.tsx:
- Line 30: Update the question-management route around the `unit` lookup and the
topic-not-found guard so access is gated only by `topik`; a missing `unit` must
not trigger the “Topik tidak ditemukan” response. Render the breadcrumb
conditionally when `unit` is available, while preserving the existing
missing-topic handling and downstream behavior.

In `@src/routes/_authenticated/admin.tsx`:
- Around line 189-195: Update isNavItemActive so the rule-key fallback cannot
activate an item whose item.to is not a path prefix of pathname; require the
item path to match as an exact path or directory prefix before applying the
shared rule-key comparison. Preserve exact-item handling and prevent shorter
same-rule entries such as /admin/peserta from competing with more specific
paths.

---

Nitpick comments:
In `@src/components/cbt/CheatSheets.tsx`:
- Around line 207-218: Remove the non-functional Inv, Keypad, History, and Ans
controls, or implement their interactions fully. If retained, convert the
interactive divs to accessible button elements with keyboard support and
meaningful handlers, and bind the existing handleDelete function to the
appropriate control instead of leaving it unused or exposing empty onClick
behavior.

In `@src/lib/server/modul/functions.ts`:
- Around line 60-72: Validate the topik payload with TopikSchema before entering
the prisma.$transaction callback, replacing the any-based casts used by the
remove, bulkSet, and upsert branches. Import TopikSchema from `@/lib/cbt/types`
and use the parsed payload so unitId is required and legacy modulId-only
requests cannot create orphan topics.

In `@src/routes/_authenticated/admin.index.tsx`:
- Around line 330-350: Update the System Environment Telemetry block in the
admin page to use existing build-time constants or a server-provided
configuration source for the database provider, ORM engine, and application
version instead of hardcoded “SQLite Local”, “Prisma Client”, and “v1.2.0”
values. Preserve the current labels and styling while ensuring the displayed
values reflect the deployed configuration and package version.

In `@src/routes/_authenticated/admin.modul.tsx`:
- Around line 35-46: Update the shown filter to remove the unreachable fallback
return after the allowedSet check, relying on canEdit for unrestricted users and
allowedSet membership otherwise. Hoist the topikRepo.all() result outside the
units.filter callback, then reuse it when determining each unit’s accessible
topics.

In `@src/routes/_authenticated/admin.tsx`:
- Around line 339-342: The admin navigation types currently allow arbitrary
strings, so invalid or removed routes bypass type checking. In
src/routes/_authenticated/admin.tsx at lines 339-342 and
src/routes/_authenticated/admin.index.tsx at lines 283 and 416-419, update
NavItem["to"], pendingTasks[].route, and ShortcutCard.href to use
FileRouteTypes["fullPaths"] or a derived admin route union, and remove the cast
that bypasses Link route validation.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a0803d38-481d-46ab-a5cf-59ca0fd58dd1

📥 Commits

Reviewing files that changed from the base of the PR and between 999b93f and 9cc0218.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (40)
  • package.json
  • prisma/schema.prisma
  • scratch/seed-clinical.ts
  • src/components/cbt/CheatSheets.tsx
  • src/lib/cbt/access.ts
  • src/lib/cbt/backup.ts
  • src/lib/cbt/repos.ts
  • src/lib/cbt/types.ts
  • src/lib/server/akademik/functions.ts
  • src/lib/server/backup/functions.ts
  • src/lib/server/db/auth.ts
  • src/lib/server/db/seed-shared.mjs
  • src/lib/server/modul/functions.ts
  • src/lib/server/redis/functions.ts
  • src/lib/server/redis/service.ts
  • src/lib/server/repos/mappers.ts
  • src/lib/server/repos/snapshot.ts
  • src/lib/server/ujian/functions.ts
  • src/lib/server/users/functions.ts
  • src/routeTree.gen.ts
  • src/routes/_authenticated/admin.akademik.mata-kuliah.tsx
  • src/routes/_authenticated/admin.analitik.$id.tsx
  • src/routes/_authenticated/admin.analitik.analisis.tsx
  • src/routes/_authenticated/admin.analitik.index.tsx
  • src/routes/_authenticated/admin.analitik.rekap.tsx
  • src/routes/_authenticated/admin.evaluasi.index.tsx
  • src/routes/_authenticated/admin.index.tsx
  • src/routes/_authenticated/admin.modul.$id.topik.tsx
  • src/routes/_authenticated/admin.modul.import-word.tsx
  • src/routes/_authenticated/admin.modul.import.tsx
  • src/routes/_authenticated/admin.modul.tsx
  • src/routes/_authenticated/admin.pengaturan.tsx
  • src/routes/_authenticated/admin.peserta.index.tsx
  • src/routes/_authenticated/admin.topik.$id.soal.tsx
  • src/routes/_authenticated/admin.tsx
  • src/routes/_authenticated/admin.ujian.$id.tsx
  • src/routes/_authenticated/admin.ujian.tsx
  • src/routes/_authenticated/admin.users.roles.tsx
  • src/routes/_authenticated/peserta.ujian.$id.index.tsx
  • src/routes/_authenticated/peserta.ujian.$id.kerjakan.tsx
💤 Files with no reviewable changes (4)
  • src/routes/_authenticated/admin.akademik.mata-kuliah.tsx
  • src/lib/server/users/functions.ts
  • src/lib/cbt/backup.ts
  • src/routeTree.gen.ts
📜 Review details
🔇 Additional comments (30)
src/routes/_authenticated/admin.peserta.index.tsx (1)

378-378: LGTM!

Also applies to: 391-391, 443-459

src/lib/server/ujian/functions.ts (1)

85-86: LGTM!

Also applies to: 101-102, 111-112

src/routes/_authenticated/admin.ujian.$id.tsx (1)

176-186: LGTM!

Also applies to: 346-346, 367-370, 560-582

src/routes/_authenticated/admin.ujian.tsx (1)

51-55: LGTM!

Also applies to: 277-314

src/components/cbt/CheatSheets.tsx (1)

17-81: LGTM!

src/routes/_authenticated/peserta.ujian.$id.kerjakan.tsx (1)

77-131: LGTM!

Also applies to: 156-185, 269-287, 347-355, 601-602, 679-680

src/lib/server/redis/functions.ts (2)

10-14: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Broken Authentication (CWE-807)

Reachability: External

Reachability path
● Entry
  src/routes/_authenticated/peserta.ujian.$id.kerjakan.tsx:77
  CheatSheetAction
│
▼
● Sink
  src/lib/server/redis/functions.ts

The timer is not server-authoritative, because the client supplies endsAt.

service.ts documents setSessionTimer as a "Server-Authoritative Timer" that "prevents client-side tampering". This handler accepts endsAt and durationMinutes from the request body and stores them without validation. A participant can post a later endsAt and extend the stored deadline.

Derive endsAt on the server from the persisted session start time and the exam durasiMenit. Accept only sesiId from the client.

#!/bin/bash
# Find every consumer of the stored timer to size the change.
rg -n -C5 'getSessionTimer|setSessionTimer' src

4-44: 🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

Authorization Bypass (CWE-862): Missing Authorization

Reachability: External

Reachability path
● Entry
  src/routes/_authenticated/peserta.ujian.$id.kerjakan.tsx:77
  CheatSheetAction
│
▼
● Sink
  src/lib/server/redis/functions.ts

Add authentication and session-ownership checks to these five server functions.

createServerFn({ method: "POST" }) publishes an HTTP endpoint. None of these five handlers identifies the caller, and none checks that the caller owns sesiId. The validators are identity functions, so the raw request body reaches the handler unchanged.

The attack path is direct. A client posts an arbitrary sesiId:

  • getTempAnswersServer returns another participant's in-progress answers.
  • saveTempAnswerServer overwrites another participant's buffered answers, which are then restored into that participant's session by the recovery code in peserta.ujian.$id.kerjakan.tsx.
  • clearSessionBufferServer destroys another participant's recovery buffer.
  • logAuditServer writes arbitrary entries into any session's audit list.

The sibling module src/lib/server/ujian/functions.ts calls requireCaller() and then authorizes the action. These handlers must do the same and must also confirm that the session belongs to the caller.

🛡️ Proposed fix pattern
 import { createServerFn } from "`@tanstack/react-start`";
+import { z } from "zod";
+import { requireCaller } from "`@/lib/server/db/auth`";
+import { prisma } from "`@/lib/server/db/client`";
 import { redisService } from "./service";
 
+async function requireOwnedSesi(sesiId: string) {
+  const caller = await requireCaller();
+  if (!caller) throw new Error("Unauthorized");
+  const sesi = await prisma.sesiUjian.findUnique({
+    where: { id: sesiId },
+    select: { pesertaId: true },
+  });
+  if (!sesi) throw new Error("Not found");
+  if (sesi.pesertaId !== caller.id && caller.role !== "super_admin") {
+    throw new Error("Forbidden");
+  }
+}
+
 export const getTempAnswersServer = createServerFn({ method: "POST" })
-  .validator((sesiId: string) => sesiId)
+  .validator(z.string().min(1))
   .handler(async ({ data: sesiId }) => {
+    await requireOwnedSesi(sesiId);
     return redisService.getTempAnswers(sesiId);
   });

Apply the same guard and a zod schema to setSessionTimerServer, saveTempAnswerServer, clearSessionBufferServer, and logAuditServer. Adjust the model and field names to match the repository.

#!/bin/bash
# Confirm no global server-function middleware supplies authentication,
# and show the authorization pattern used by other server functions.
fd -e ts -e tsx . src --exec rg -ln 'createMiddleware|globalMiddleware|registerGlobalMiddleware' {}
rg -n -C4 'requireCaller' src/lib/server | head -60
fd -g 'start.ts' -g 'start.tsx' -g 'router.tsx' . src
src/routes/_authenticated/admin.pengaturan.tsx (1)

66-66: 📐 Maintainability & Code Quality

No change needed.

neo-ready is defined in src/styles.css under [data-theme='neobrutalism'], so the class has visual behavior for the target theme.

src/routes/_authenticated/admin.tsx (1)

212-238: 🩺 Stability & Availability

No auto-expand loop here.

configRepo.get() returns the module-level cache.config reference, and current configRepo.set() paths assign through that same cache without recreating the object, so this memo/effect dependency pattern does not create the described render loop.

			> Likely an incorrect or invalid review comment.
package.json (1)

60-60: LGTM!

prisma/schema.prisma (2)

45-45: LGTM!

Also applies to: 56-56, 83-85, 135-136


124-125: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Authorization Bypass (CWE-862): Missing Authorization

Reachability: External

Apply the cohort policy before returning participant exams.

angkatanIds is persisted here, but the supplied participant snapshot checks only groupIds and caller.unitId. A participant in an allowed unit but outside the configured cohort receives the exam and its token rows. Use one server-side predicate that evaluates cohort restrictions and the isUmum policy in the snapshot and exam-start paths.

#!/bin/bash
set -euo pipefail

ast-grep outline src/lib/server/repos/snapshot.ts --items all --match 'pesertaSnapshot'
ast-grep outline src/lib/server/db/auth.ts --items all --match 'pesertaCanTouchUjian'
ast-grep outline src/lib/cbt/access.ts --items all --match 'isParticipantAssignedToExam'

rg -n -C 8 'angkatanIds|isUmum|groupIds|pesertaSnapshot|pesertaCanTouchUjian|isParticipantAssignedToExam' \
  prisma/schema.prisma \
  src/lib/server/repos/snapshot.ts \
  src/lib/server/db/auth.ts \
  src/lib/cbt/access.ts
src/lib/server/repos/mappers.ts (1)

2-2: LGTM!

Also applies to: 60-60, 96-96, 142-143, 152-153

src/lib/server/repos/snapshot.ts (1)

18-19: LGTM!

Also applies to: 42-43, 142-156

src/lib/server/backup/functions.ts (1)

7-7: LGTM!

src/lib/server/db/seed-shared.mjs (1)

222-227: LGTM!

Also applies to: 573-573

src/lib/server/db/auth.ts (1)

28-28: LGTM!

Also applies to: 90-90

src/routes/_authenticated/admin.analitik.analisis.tsx (1)

3-3: LGTM!

Also applies to: 40-40, 121-123

src/routes/_authenticated/admin.analitik.index.tsx (1)

3-3: LGTM!

Also applies to: 23-24

src/routes/_authenticated/admin.analitik.rekap.tsx (1)

3-3: LGTM!

Also applies to: 117-119

src/routes/_authenticated/admin.evaluasi.index.tsx (1)

4-4: LGTM!

Also applies to: 39-39, 50-50, 62-62, 135-135, 150-150, 170-170

src/routes/_authenticated/peserta.ujian.$id.index.tsx (2)

3-3: LGTM!

Also applies to: 204-209


177-179: 🔒 Security & Privacy

Information Disclosure (CWE-209): Generation of Error Message Containing Sensitive Information

Reachability: External

Verify that unexpected error messages are safe for participants.

This path exposes every non-assignment Error.message in a participant toast. Keep a fixed user-safe message unless all errors from findOrCreateSesi and startSesi are safe to disclose.

#!/bin/bash
set -euo pipefail

ast-grep outline src/lib/cbt/exam.ts --items all --match 'findOrCreateSesi|startSesi'
rg -n -C 5 'findOrCreateSesi|startSesi|throw new Error|throw new [A-Za-z]+Error' src/lib/cbt/exam.ts
src/lib/cbt/types.ts (1)

57-57: LGTM!

Also applies to: 70-70, 124-125, 134-135

src/lib/cbt/repos.ts (1)

5-10: LGTM!

Also applies to: 93-104, 162-193, 275-281

src/lib/server/akademik/functions.ts (1)

8-8: LGTM!

src/routes/_authenticated/admin.modul.$id.topik.tsx (1)

3-3: LGTM!

Also applies to: 20-38, 40-43, 79-83

src/routes/_authenticated/admin.analitik.$id.tsx (1)

4-4: LGTM!

Also applies to: 63-68

src/lib/cbt/access.ts (1)

128-136: 🔒 Security & Privacy | ⚡ Quick win

Authorization Bypass (CWE-862): Missing Authorization

Reachability: External

Reachability path
● Entry
  src/routes/_authenticated/admin.ujian.tsx:51
  add
│
▼
● Hop
  src/routes/_authenticated/admin.modul.$id.topik.tsx
│
▼
● Sink
  src/lib/cbt/access.ts

Confirm the fail-open default for exams without restrictions.

If groupIds and angkatanIds are both empty and isUmum is false, the function returns true. Every authenticated participant then passes the check at src/routes/_authenticated/peserta.ujian.$id.index.tsx line 54. UjianSchema defaults both fields to empty, so an exam saved without a cohort selection is open to all participants. Confirm that this is the intended product rule, or require isUmum for unrestricted exams.

Run the following script to check whether the exam editor always writes a restriction:

#!/bin/bash
# Description: Inspect how angkatanIds, groupIds, and isUmum are set when an exam is created or edited.
rg -n -C 6 'angkatanIds|isUmum' --glob 'src/routes/**' --glob 'src/lib/server/ujian/**'

Comment thread scratch/seed-clinical.ts
Comment on lines +8 to +21
const modulId = (await prisma.modul.findFirst())?.id;
if (!modulId) throw new Error("No modul found");

const adminId = (await prisma.user.findFirst())?.id;
if (!adminId) throw new Error("No admin found");

const topikId = uid();
await prisma.topik.create({
data: {
id: topikId,
modulId,
nama: "Keperawatan & Farmasi - Hitungan & Nilai Normal"
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the Topik model fields and whether a Modul model still exists.
fd -g 'schema.prisma' --exec rg -n -A20 'model (Topik|Modul|UnitAkademik)' {}

Repository: mannnrachman/cbt-man

Length of output: 1465


🏁 Script executed:

#!/bin/bash
# Inspect the seed script changes and any remaining seed references to modul/modulId.
set -euo pipefail

printf '--- scratch/seed-clinical.ts ---\n'
if [ -f scratch/seed-clinical.ts ]; then
  nl -ba scratch/seed-clinical.ts | sed -n '1,80p'
else
  echo 'scratch/seed-clinical.ts not found'
fi

printf '\n--- repository seed files mentioning modul/modulId/unitId ---\n'
rg -n 'modulId|prisma\.modul|unitId|prisma\.unitAkademik|unitAkademik' .

printf '\n--- files containing model Modul ---\n'
rg -n 'model Modul' .

Repository: mannnrachman/cbt-man

Length of output: 249


🏁 Script executed:

#!/bin/bash
# Inspect the seed script changes and any remaining seed references to modul/modulId.
set -euo pipefail

printf '%s\n' '--- scratch/seed-clinical.ts ---'
if [ -f scratch/seed-clinical.ts ]; then
  nl -ba scratch/seed-clinical.ts | sed -n '1,80p'
else
  echo 'scratch/seed-clinical.ts not found'
fi

printf '%s\n' ''
printf '%s\n' '--- repository seed files mentioning modul/modulId/unitId ---'
rg -n 'modulId|prisma\.modul|unitId|prisma\.unitAkademik|unitAkademik' .

printf '%s\n' ''
printf '%s\n' '--- files containing model Modul ---'
rg -n 'model Modul' .

Repository: mannnrachman/cbt-man

Length of output: 231


🏁 Script executed:

#!/bin/bash
# Inspect the seed script and relevant model references without nl.
set -euo pipefail

echo '--- scratch/seed-clinical.ts ---'
if [ -f scratch/seed-clinical.ts ]; then
  awk '{printf "%5d\t%s\n", NR, $0}' scratch/seed-clinical.ts | sed -n '1,80p'
else
  echo 'scratch/seed-clinical.ts not found'
fi

echo ''
echo '--- repository file references to modul/modulId/unitId ---'
rg -n 'modulId|unitId|prisma\.unitAkademik|prisma\.modul' . || true

echo ''
echo '--- files containing model Modul ---'
rg -n 'model Modul' . || true

Repository: mannnrachman/cbt-man

Length of output: 15636


Update the clinical seed to use the UnitAkademik model.

Topik now stores unitId, UnitAkademik exists, and model Modul is no longer present. Reading prisma.modul.findFirst() and creating prisma.topik with modulId will fail before the script can run.

🐛 Proposed change
-  const modulId = (await prisma.modul.findFirst())?.id;
+  const unitId = (await prisma.unitAkademik.findFirst())?.id;
   if (!modulId) throw new Error("No modul found");
+  if (!unitId) throw new Error("No unit akademik found");

   await prisma.topik.create({
     data: {
       id: topikId,
-      modulId,
+      unitId,
       nama: "Keperawatan & Farmasi - Hitungan & Nilai Normal"
     }
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scratch/seed-clinical.ts` around lines 8 - 21, Update the clinical seed setup
to fetch the required identifier from the UnitAkademik model instead of calling
prisma.modul.findFirst(), and rename the local identifier accordingly. In the
prisma.topik.create data, replace modulId with the Topik model’s unitId field
and pass the UnitAkademik identifier, preserving the existing missing-record
validation.

Comment on lines +83 to +131
function evaluateMathExpression(expr: string): number {
const sanitized = expr.replace(/÷/g, "/").replace(/×/g, "*").replace(/−/g, "-");
if (!/^[0-9\s.+\-*/%()]+$/.test(sanitized)) {
throw new Error("Invalid math expression");
}

const expanded = sanitized.replace(/(\d+(?:\.\d+)?)%/g, "($1/100)");
const tokens = expanded.match(/\d+(?:\.\d+)?|[+\-*/()]/g);
if (!tokens) throw new Error("No tokens");

let pos = 0;

function parsePrimary(): number {
if (pos >= tokens!.length) throw new Error("Unexpected end");
const token = tokens![pos++];
if (token === "(") {
const val = parseExpr();
if (tokens![pos++] !== ")") throw new Error("Missing closing paren");
return val;
}
const num = parseFloat(token);
if (isNaN(num)) throw new Error("Invalid number: " + token);
return num;
}

function parseMultiplicative(): number {
let left = parsePrimary();
while (pos < tokens!.length && (tokens![pos] === "*" || tokens![pos] === "/")) {
const op = tokens![pos++];
const right = parsePrimary();
left = op === "*" ? left * right : (right !== 0 ? left / right : NaN);
}
return left;
}

function parseExpr(): number {
let left = parseMultiplicative();
while (pos < tokens!.length && (tokens![pos] === "+" || tokens![pos] === "-")) {
const op = tokens![pos++];
const right = parseMultiplicative();
left = op === "+" ? left + right : left - right;
}
return left;
}

const result = parseExpr();
if (isNaN(result) || !isFinite(result)) throw new Error("Math error");
return result;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

The parser and the keypad do not agree. Three defects follow.

  1. ^ always fails. The xʸ button on Line 234 inserts ^. The allowlist on Line 85 rejects ^, so calculate throws and the display becomes Error. The power button is unusable.
  2. Leftover tokens are accepted silently. parseExpr returns after the top-level loop ends. The code never checks that pos === tokens.length. Example: 5%3 expands to (5/100)3, tokenizes to ( 5 / 100 ) 3, and returns 0.05. The trailing 3 is discarded and the participant sees a wrong answer with no error.
  3. The mod button computes percent. Line 251 inserts %, and Line 89 rewrites n% into (n/100). mod therefore never performs a modulo operation.

Participants use this tool for scored clinical calculations, so a silently wrong result is worse than an error.

🐛 Proposed fix: reject leftover tokens and support `^`
 function evaluateMathExpression(expr: string): number {
   const sanitized = expr.replace(/÷/g, "/").replace(/×/g, "*").replace(/−/g, "-");
-  if (!/^[0-9\s.+\-*/%()]+$/.test(sanitized)) {
+  if (!/^[0-9\s.+\-*/%^()]+$/.test(sanitized)) {
     throw new Error("Invalid math expression");
   }
 
   const expanded = sanitized.replace(/(\d+(?:\.\d+)?)%/g, "($1/100)");
-  const tokens = expanded.match(/\d+(?:\.\d+)?|[+\-*/()]/g);
+  const tokens = expanded.match(/\d+(?:\.\d+)?|[+\-*/^()]/g);
   if (!tokens) throw new Error("No tokens");
+  function parsePower(): number {
+    const base = parsePrimary();
+    if (pos < tokens!.length && tokens![pos] === "^") {
+      pos++;
+      return Math.pow(base, parsePower()); // right associative
+    }
+    return base;
+  }
+
   function parseMultiplicative(): number {
-    let left = parsePrimary();
+    let left = parsePower();
     while (pos < tokens!.length && (tokens![pos] === "*" || tokens![pos] === "/")) {
       const op = tokens![pos++];
-      const right = parsePrimary();
+      const right = parsePower();
       left = op === "*" ? left * right : (right !== 0 ? left / right : NaN);
     }
     return left;
   }
   const result = parseExpr();
+  if (pos !== tokens.length) throw new Error("Unexpected trailing input");
   if (isNaN(result) || !isFinite(result)) throw new Error("Math error");
   return result;
 }

Also relabel or reimplement the mod button on Line 251, because % is bound to percent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/cbt/CheatSheets.tsx` around lines 83 - 131, Update
evaluateMathExpression to allow and parse the ^ operator with appropriate power
precedence, and reject any expression where pos does not equal tokens.length
after parseExpr completes. Resolve the keypad conflict by changing the mod
button’s behavior or label so it performs modulo rather than the existing
percent expansion, while preserving percent parsing for percentage inputs.

Comment thread src/lib/cbt/access.ts
Comment on lines 87 to 101
export function ujianTouchesAllowed(user: User | null | undefined, ujian: Ujian): boolean {
if (isUnrestricted(user)) return true;
const mkSet = new Set(user?.mataKuliahIds ?? []);
if (ujian.mataKuliahId && mkSet.has(ujian.mataKuliahId)) return true;
const set = new Set(user?.allowedTopikIds ?? []);
if (set.size === 0 && mkSet.size === 0) return true; // unrestricted (though handled above)
if (set.size === 0) return true; // unrestricted (though handled above)
return ujian.topicSets.length > 0 && ujian.topicSets.every((ts) => set.has(ts.topikId));
}

export function visibleUjians(user: User | null | undefined) {
if (isUnrestricted(user)) return ujianRepo.all();
const set = new Set(user?.allowedTopikIds ?? []);
const mkSet = new Set(user?.mataKuliahIds ?? []);
const all = ujianRepo.all();
return all.filter((u) =>
(u.mataKuliahId && mkSet.has(u.mataKuliahId)) ||
u.topicSets.some((ts) => set.has(ts.topikId))
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Align exam visibility with the edit gate.

visibleUjians uses some, but ujianTouchesAllowed uses every. A restricted operator therefore sees an exam in the list that the editor rejects. src/routes/_authenticated/admin.ujian.$id.tsx calls ujianTouchesAllowed at lines 83 and 105 to block that exam. Choose one rule for both functions.

♻️ Proposed change to make listing match the edit gate
   return all.filter((u) => 
-    u.topicSets.some((ts) => set.has(ts.topikId))
+    u.topicSets.length > 0 && u.topicSets.every((ts) => set.has(ts.topikId))
   );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function ujianTouchesAllowed(user: User | null | undefined, ujian: Ujian): boolean {
if (isUnrestricted(user)) return true;
const mkSet = new Set(user?.mataKuliahIds ?? []);
if (ujian.mataKuliahId && mkSet.has(ujian.mataKuliahId)) return true;
const set = new Set(user?.allowedTopikIds ?? []);
if (set.size === 0 && mkSet.size === 0) return true; // unrestricted (though handled above)
if (set.size === 0) return true; // unrestricted (though handled above)
return ujian.topicSets.length > 0 && ujian.topicSets.every((ts) => set.has(ts.topikId));
}
export function visibleUjians(user: User | null | undefined) {
if (isUnrestricted(user)) return ujianRepo.all();
const set = new Set(user?.allowedTopikIds ?? []);
const mkSet = new Set(user?.mataKuliahIds ?? []);
const all = ujianRepo.all();
return all.filter((u) =>
(u.mataKuliahId && mkSet.has(u.mataKuliahId)) ||
u.topicSets.some((ts) => set.has(ts.topikId))
);
}
export function ujianTouchesAllowed(user: User | null | undefined, ujian: Ujian): boolean {
if (isUnrestricted(user)) return true;
const set = new Set(user?.allowedTopikIds ?? []);
if (set.size === 0) return true; // unrestricted (though handled above)
return ujian.topicSets.length > 0 && ujian.topicSets.every((ts) => set.has(ts.topikId));
}
export function visibleUjians(user: User | null | undefined) {
if (isUnrestricted(user)) return ujianRepo.all();
const set = new Set(user?.allowedTopikIds ?? []);
const all = ujianRepo.all();
return all.filter((u) =>
u.topicSets.length > 0 && u.topicSets.every((ts) => set.has(ts.topikId))
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/cbt/access.ts` around lines 87 - 101, Align visibleUjians with
ujianTouchesAllowed by applying the same every-topic authorization rule when
filtering restricted users, replacing the current some-based predicate while
preserving unrestricted-user behavior.

Comment on lines +16 to +31
private memoryFallback: Map<string, { data: unknown, expiresAt: number | null }> = new Map();

private setFallback(key: string, data: unknown, ttlSeconds?: number) {
const expiresAt = ttlSeconds ? Date.now() + ttlSeconds * 1000 : null;
this.memoryFallback.set(key, { data, expiresAt });
}

private getFallback(key: string): unknown | null {
const entry = this.memoryFallback.get(key);
if (!entry) return null;
if (entry.expiresAt && Date.now() > entry.expiresAt) {
this.memoryFallback.delete(key);
return null;
}
return entry.data;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

memoryFallback grows without bound.

getFallback removes an expired entry only when that exact key is read again. Nothing sweeps the map. Audit log keys are written with a 7-day TTL on Line 203 and are never read on the normal path, and clearSessionBuffer deletes only the answer key and the timer key. In a long-running server the map therefore accumulates one entry per session that ever used the fallback, each holding every buffered answer.

redisService is a module-level singleton, so the map lives for the process lifetime.

Add a periodic sweep or a bounded cache, and delete the log key in clearSessionBuffer.

♻️ Proposed change
   private getFallback(key: string): unknown | null {
     const entry = this.memoryFallback.get(key);
     if (!entry) return null;
     if (entry.expiresAt && Date.now() > entry.expiresAt) {
       this.memoryFallback.delete(key);
       return null;
     }
     return entry.data;
   }
+
+  private sweepFallback() {
+    const now = Date.now();
+    for (const [key, entry] of this.memoryFallback) {
+      if (entry.expiresAt && now > entry.expiresAt) this.memoryFallback.delete(key);
+    }
+  }

Start the sweep on an interval in the constructor, and call unref() on the timer so it does not hold the process open.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/server/redis/service.ts` around lines 16 - 31, Prevent the
module-level Redis fallback cache from growing indefinitely: in the service
constructor, start a periodic sweep that removes expired entries from
memoryFallback and call unref() on the interval timer. Also update
clearSessionBuffer to delete the audit log key alongside the existing answer and
timer keys, while preserving current fallback expiration behavior in setFallback
and getFallback.

Comment on lines +33 to +59
constructor() {
const redisUrl = process.env.REDIS_URL || "redis://127.0.0.1:6379";
try {
this.redis = new Redis(redisUrl, {
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
retryStrategy: (times) => {
if (times > 5) return null; // stop retrying after 5 attempts to avoid blocking
return Math.min(times * 100, 3000);
},
});

this.redis.on("connect", () => {
this.isConnected = true;
console.log("[Redis] Connected successfully to Redis server.");
});

this.redis.on("error", (err) => {
if (this.isConnected) {
console.warn("[Redis] Disconnected or Connection Error:", err.message);
}
this.isConnected = false;
});
} catch {
this.isConnected = false;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

ioredis retryStrategy return null stop reconnecting ready end events

💡 Result:

In ioredis, the retryStrategy function dictates the behavior when a connection to Redis is lost [1][2]. If this function returns a value that is not a number (such as null, undefined, or false), ioredis will stop attempting to reconnect [1][3][2]. When the retryStrategy returns a non-numeric value, the client emits an end event, indicating that no further automatic reconnection attempts will be made [4][5]. At this point, the connection is considered permanently closed unless you manually call redis.connect [1][3][2]. To stop reconnecting, you can implement the strategy as follows [3]: const redis = new Redis({ retryStrategy(times) { // Returning null or false stops the reconnection process return null; } }); redis.on('end', => { console.log('Connection has ended and no further reconnections will be attempted.'); }); In this scenario, the 'end' event will be triggered after the retryStrategy returns a non-numeric value, effectively terminating the lifecycle of the automatic connection manager [4][5]. Any pending commands in the queue will be flushed with an error, typically reflecting that the connection has been closed [4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate relevant files =="
fd -a 'service\.ts$|package\.json$' . | sed 's#^\./##' | head -100

echo
echo "== redis service file =="
cat -n src/lib/server/redis/service.ts

echo
echo "== package manifests with ioredis refs =="
rg -n '"ioredis"|ioredis' package.json $(git ls-files '*package*.json') || true

echo
echo "== usages of RedisService/isConnected/fallback =="
rg -n "RedisService|isConnected|memoryFallback|fallback" src -S || true

Repository: mannnrachman/cbt-man

Length of output: 13290


🌐 Web query:

ioredis 5.11.1 retryStrategy reconnect automatically end event ready event

💡 Result:

In ioredis 5.11.1, automatic reconnection is enabled by default [1][2]. The library attempts to reconnect whenever the connection to the Redis server is lost, provided the disconnection was not triggered manually by calling redis.disconnect or redis.quit [1][3]. The behavior of these reconnections is controlled by the retryStrategy option [1][2]. retryStrategy The retryStrategy is a function that determines the delay (in milliseconds) before the next reconnection attempt [1][2]. It receives a times argument, which indicates the number of reconnection attempts made so far [1][2]. If the function returns a number, the client will wait for that many milliseconds before attempting to reconnect [1][3]. If the function does not return a number (e.g., returns null or undefined), ioredis will stop attempting to reconnect, and the connection status will transition to end [1][4][2]. Events The following events are relevant to the connection lifecycle: ready: Emitted when the client is fully connected and ready to process commands [5]. If the enableReadyCheck option is true (default), this event is emitted only after the server reports it is ready (e.g., it has finished loading data from disk) [6][7]. If enableReadyCheck is false, it is emitted immediately after the connect event [5][8]. end: Emitted after the connection has closed and the client has stopped attempting further reconnections [4][5]. This occurs if the connection was closed manually, if the retryStrategy stopped returning a number, or if the connection failed permanently [1][4][5]. Summary of Reconnection Flow When the connection is lost, ioredis emits a close event [5]. If the connection was not closed manually, the client checks the retryStrategy [4]. If a valid delay is returned, it emits a reconnecting event and initiates a timer for the next attempt [4][5]. Upon successful reconnection, the client emits a connect event, followed by a ready event once the server is prepared to handle commands [5][6]. If all retries fail or the strategy dictates stopping, it eventually emits an end event [4][5].

Citations:


Keep the Redis client reconnecting after transient failures.

retryStrategy returns null after 5 attempts, so ioredis stops reconnection attempts and emits end. After that, no connect/ready event can fire, isConnected stays false, and all service calls fall back to process-local memory for the lifetime of the client. Keep retryStrategy returning a number with a capped backoff, update isConnected from ready, and log when the final end event occurs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/server/redis/service.ts` around lines 33 - 59, Update the Redis
client setup in the constructor so retryStrategy always returns a capped numeric
backoff instead of null, allowing reconnection after transient failures. Set
isConnected from the Redis ready event rather than connect, and add an end-event
handler that logs when reconnection has finally stopped while preserving the
disconnected state.

Comment thread src/routes/_authenticated/admin.tsx Outdated
Comment on lines +452 to +506

<div className="flex items-center justify-between rounded border p-2 bg-blue-50/50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800">
<div>
<Label className="text-blue-700 dark:text-blue-400">Ujian Umum (Mata Kuliah Umum)</Label>
<p className="text-xs text-blue-600/70 dark:text-blue-400/70">
Bila diaktifkan, ujian ini terbuka untuk semua prodi dan angkatan
</p>
</div>
<Switch checked={u.isUmum} onCheckedChange={(v) => set("isUmum", v)} />
</div>

{!u.isUmum && (
<>
<div className="space-y-1 mt-4">
<Label className="text-xs">Program Studi / Unit (Kosong = Semua Prodi)</Label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{groups.map((g) => (
<label key={g.id} className="flex items-center gap-2 rounded border p-2 text-sm">
<Checkbox
checked={u.groupIds.includes(g.id)}
onCheckedChange={(v) =>
set(
"groupIds",
v ? [...u.groupIds, g.id] : u.groupIds.filter((x) => x !== g.id),
)
}
/>
{g.nama}
</label>
))}
</div>
</div>

<div className="space-y-2 mt-4 pt-4 border-t">
<Label className="text-xs">Tahun Angkatan (Kosong = Semua Angkatan)</Label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{availableAngkatan.map((angkatan) => (
<label key={angkatan} className="flex items-center gap-2 rounded border p-2 text-sm">
<Checkbox
checked={u.angkatanIds?.includes(angkatan) ?? false}
onCheckedChange={(v) => {
const current = u.angkatanIds ?? [];
set(
"angkatanIds",
v ? [...current, angkatan] : current.filter((x) => x !== angkatan)
);
}}
/>
{angkatan}
</label>
))}
</div>
</div>
</>
)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect participant-side access logic for isUmum / angkatanIds handling.
fd -e ts -e tsx . src/lib/cbt src/lib/server --exec rg -n -C6 'isUmum|angkatanIds' {}

Repository: mannnrachman/cbt-man

Length of output: 4599


🏁 Script executed:

#!/bin/bash
# Locate and inspect the admin edit component around the visibleUjians / participant access flow and the isUmum form state.
fd -e ts -e tsx . src routes --exec sh -c '
  echo "== file: $1 =="
  rg -n "visibleUjians|isParticipantAssignedToExam|setU|set\\(\"isUmum\"|isUmum|groupIds|angkatanIds" "$1" || true
' sh {}
# Read focused sections from route and library files.
for f in \
  "src/routes/_authenticated/admin.ujian.$id.tsx" \
  "src/lib/cbt/participant/ujian.ts"
do
  if [ -f "$f" ]; then
    echo "== $f line count =="
    wc -l "$f"
    echo "== $f isUmum visibility/access section =="
    rg -n -C8 "isParticipantAssignedToExam|visibleUjians|isUmum|groupIds|angkatanIds" "$f"
  fi
done

Repository: mannnrachman/cbt-man

Length of output: 12443


🏁 Script executed:

#!/bin/bash
# Read the core access implementation and focused route sections.
for f in \
  "src/lib/cbt/access.ts" \
  "src/lib/server/ujian/functions.ts" \
  "src/routes/_authenticated/admin.ujian.tsx" \
  "src/routes/_authenticated/admin.ujian.$id.tsx"
do
  if [ -f "$f" ]; then
    echo "== $f =="
    wc -l "$f"
  fi
done

echo "== src/lib/cbt/access.ts =="
sed -n '90,170p' src/lib/cbt/access.ts

echo "== src/lib/server/ujian/functions.ts visible/assignment sections =="
sed -n '350,410p' src/lib/server/ujian/functions.ts

echo "== src/routes/_authenticated/admin.ujian.tsx =="
sed -n '1,140p' src/routes/_authenticated/admin.ujian.tsx

echo "== src/routes/_authenticated/admin.ujian.$id.tsx form/save sections =="
sed -n '70,115p' src/routes/_authenticated/admin.ujian.$id.tsx
sed -n '180,215p' src/routes/_authenticated/admin.ujian.$id.tsx
sed -n '448,502p' src/routes/_authenticated/admin.ujian.$id.tsx

Repository: mannnrachman/cbt-man

Length of output: 11086


Clear stale restrictions when enabling isUmum.

groupIds and angkatanIds can remain populated while isUmum is true. visibleUjians still returns the exam, but fetchUjianByIdServer checks row.groupIds directly for mahasiswa callers and returns Forbidden before exposing the public exam. Clear both arrays when the switch is enabled; when disabling, keep prior values if they may have been valid for future restriction edits.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/_authenticated/admin.ujian`.$id.tsx around lines 452 - 506, Update
the isUmum switch handler in the Ujian form to clear both groupIds and
angkatanIds when enabling the public-exam mode, preventing stale restrictions
from blocking mahasiswa access. When disabling isUmum, preserve the existing
restriction arrays rather than resetting them.

Comment on lines +95 to +101
ujianRepo.upsert(u);
await ujianRepo.flush();
setList((current) => [...current, u]);
toast.success("Ujian baru dibuat — silakan edit");
setIsAddDialogOpen(false);
setIsAdding(false);
navigate({ to: "/admin/ujian/$id", params: { id: u.id } });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Determine the return type of flush() on the repos.
rg -n -C10 'flush' src/lib/cbt/repos.ts

Repository: mannnrachman/cbt-man

Length of output: 1248


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== route submitAdd context =="
cat -n src/routes/_authenticated/admin.ujian.tsx | sed -n '60,120p'

echo
echo "== MutationResult definition/usages =="
rg -n -C4 'type MutationResult|interface MutationResult|MutationResult|\.ok|flush\(' src/lib/cbt repos || true

echo
echo "== error handling in related files =="
rg -n -C6 'await .*Repo\.flush|result\.ok' src/routes -e 'tsx' | sed -n '1,220p'

echo
echo "== ujianRepo declaration context =="
cat -n src/lib/cbt/repos.ts | sed -n '1,260p'

Repository: mannnrachman/cbt-man

Length of output: 9254


Handle all flush outcomes in submitAdd.

ujianRepo.flush() can return { ok: false } or reject. In each case, show the failure toast, keep the page on the list view, update setList to remove the optimistic entry, and reset isAdding so the add controls stay usable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/_authenticated/admin.ujian.tsx` around lines 95 - 101, Update
submitAdd around ujianRepo.flush() to handle both a rejected promise and an {
ok: false } result: show the failure toast, keep the list view open, remove the
optimistic entry through setList, and reset isAdding. Only append the entry,
show the success toast, close the dialog, and navigate to the edit route after a
successful flush.

Comment thread src/routes/_authenticated/peserta.ujian.$id.kerjakan.tsx Outdated
Comment thread src/routes/_authenticated/peserta.ujian.$id.kerjakan.tsx Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/routes/_authenticated/peserta.ujian.$id.index.tsx (1)

177-180: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Bring token claims under the same user-facing error guard.

claimExamToken() returns { ok: false } in the normal token-error paths, but this handler only catches exceptions from session creation. If claimExamToken() throws, mulai() rejects with an unhandled participant-facing error. Wrap the earlier claim path in the same generic fallback or guarantee all server failures return { ok: false, error }.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/_authenticated/peserta.ujian`.$id.index.tsx around lines 177 -
180, Update mulai() so the claimExamToken() call is covered by the same generic
participant-facing error guard as session creation. Handle thrown claim errors
by showing the existing “Gagal memulai ujian” fallback and returning, while
preserving the current { ok: false } handling for normal token failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/server/db/auth.ts`:
- Line 112: Remove the any cast at the isParticipantAssignedToExam authorization
call and make the argument satisfy its participant contract through a shared
minimal participant type or a typed adapter mapping unitId and angkatan.
Preserve the existing authorization behavior and avoid replacing the cast with
another broad assertion.

---

Nitpick comments:
In `@src/routes/_authenticated/peserta.ujian`.$id.index.tsx:
- Around line 177-180: Update mulai() so the claimExamToken() call is covered by
the same generic participant-facing error guard as session creation. Handle
thrown claim errors by showing the existing “Gagal memulai ujian” fallback and
returning, while preserving the current { ok: false } handling for normal token
failures.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8a4a7094-25fe-432b-8a7e-6736b10231c0

📥 Commits

Reviewing files that changed from the base of the PR and between 9cc0218 and 43c65ff.

📒 Files selected for processing (9)
  • prisma/schema.prisma
  • src/lib/cbt/access.ts
  • src/lib/server/backup/functions.ts
  • src/lib/server/db/auth.ts
  • src/routes/_authenticated/admin.index.tsx
  • src/routes/_authenticated/admin.peserta.index.tsx
  • src/routes/_authenticated/admin.ujian.$id.tsx
  • src/routes/_authenticated/admin.ujian.tsx
  • src/routes/_authenticated/peserta.ujian.$id.index.tsx
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/routes/_authenticated/admin.peserta.index.tsx
  • prisma/schema.prisma
  • src/lib/server/backup/functions.ts
  • src/lib/cbt/access.ts
  • src/routes/_authenticated/admin.index.tsx
  • src/routes/_authenticated/admin.ujian.$id.tsx
📜 Review details
⚠️ CI failures not shown inline (2)

GitHub Actions: CI / CI: feat: revamp bank soal per jurusan, add angkatan filter checkboxes

Conclusion: failure

View job details

##[group]Run npm run lint -- --max-warnings=7
 �[36;1mnpm run lint -- --max-warnings=7�[0m
 shell: /usr/bin/bash -e {0}
 ##[endgroup]
 > lint
 > eslint . --max-warnings=7
 /home/runner/work/cbt-man/cbt-man/scratch/seed-clinical.ts
 ##[error]  119:25  error  Unexpected any. Specify a different type  `@typescript-eslint/no-explicit-any`

GitHub Actions: CI / 0_CI.txt: feat: revamp bank soal per jurusan, add angkatan filter checkboxes

Conclusion: failure

View job details

##[group]Run npm run lint -- --max-warnings=7
 �[36;1mnpm run lint -- --max-warnings=7�[0m
 shell: /usr/bin/bash -e {0}
 ##[endgroup]
 > lint
 > eslint . --max-warnings=7
 /home/runner/work/cbt-man/cbt-man/scratch/seed-clinical.ts
 ##[error]  119:25  error  Unexpected any. Specify a different type  `@typescript-eslint/no-explicit-any`
🧰 Additional context used
🪛 ESLint
src/lib/server/db/auth.ts

[error] 112-112: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)

🪛 GitHub Check: CI
src/lib/server/db/auth.ts

[failure] 112-112:
Unexpected any. Specify a different type

🔇 Additional comments (8)
src/lib/server/db/auth.ts (3)

71-71: 🎯 Functional Correctness

No callers remain after removing operatorCanTouchModul.


28-28: 🔒 Security & Privacy

Authorization Bypass (CWE-863): Incorrect Authorization

Complete the legacy permission migration before dropping mataKuliahIds.

If an existing operator has a non-empty mataKuliahIds value but no migrated topic permissions, the empty topic-permission branch can authorize every topic. If the new list is non-empty, removing the fallback can deny access that the stored permission still grants. Prove that the migration backfills equivalent topic permissions or clears the legacy field before enabling this path.

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 8 \
  '\b(mataKuliahIds|topikIds|topicIds|operatorCanTouchTopikId)\b' \
  --glob '*.ts' --glob '*.tsx' --glob '*.mjs' --glob 'schema.prisma'

Also applies to: 57-58


90-96: 🔒 Security & Privacy

Authorization Bypass (CWE-863): Incorrect Authorization

Fail closed for exams without topic scope.

operatorCanTouchTopicSets returns true for an empty list at Lines 65-68. parseJson also falls back to []. Because this query removes mataKuliahId, a missing, malformed, or empty topicSets value can authorize an operator without a topic match unless an earlier guard rejects it. Reject unscoped records or prove that every reachable exam has valid topic sets.

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 10 \
  '\b(operatorCanTouchUjian|operatorCanTouchTopicSets|topicSets|mataKuliahId)\b' \
  --glob '*.ts' --glob '*.tsx' --glob '*.mjs' --glob 'schema.prisma'
src/routes/_authenticated/admin.ujian.tsx (2)

70-74: Duplicate: handle every ujianRepo.flush() outcome.

At Line [71], add() ignores the flush result. If it returns { ok: false }, Lines [72]-[74] still update the list, show success, and clear isAdding. If it rejects, isAdding remains set and the rejection is unhandled. This is the same unresolved issue from the previous review comment. Handle both outcomes before updating the UI.


8-8: LGTM!

Also applies to: 42-69, 89-90, 114-115

src/routes/_authenticated/peserta.ujian.$id.index.tsx (3)

177-180: 🗄️ Data Integrity & Integration

Verify token/session consistency after a start failure.

The token is claimed at Line [153] before findOrCreateSesi(), startSesi(), and sesiRepo.upsert() at Lines [167]-[169]. If any of those operations fails, Lines [177]-[180] return without releasing or reconciling the token. Confirm that the server claim is idempotent for the same participant or provides a rollback path.


3-3: LGTM!

Also applies to: 186-195, 204-215, 224-224, 337-337, 347-347, 362-362


177-180: 🔒 Security & Privacy

Information Disclosure (CWE-209): Generation of Error Message Containing Sensitive Information

Reachability: External

Verify that token-claim errors are sanitized.

The generic message at Line [178] covers only errors after the token claim. src/lib/cbt/repos.ts passes claimExamTokenServer’s result.error through, and Line [155] displays claim.error directly. If that server error contains exception details, the participant can see internal information. Return stable user-facing token errors and map unexpected failures to the generic message.

Comment thread src/lib/server/db/auth.ts

);
const angkatanIds = parseJson<string[]>(ujian.angkatanIds, []);
return isParticipantAssignedToExam(caller as any, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Remove the any cast from the authorization boundary.

The supplied CI and ESLint results report @typescript-eslint/no-explicit-any at Line 112. The cast also hides whether UserRow satisfies the helper contract for unitId and angkatan. Define a shared minimal participant type or map these fields into a typed adapter. Do not replace this with another broad assertion.

🧰 Tools
🪛 ESLint

[error] 112-112: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)

🪛 GitHub Check: CI

[failure] 112-112:
Unexpected any. Specify a different type

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/server/db/auth.ts` at line 112, Remove the any cast at the
isParticipantAssignedToExam authorization call and make the argument satisfy its
participant contract through a shared minimal participant type or a typed
adapter mapping unitId and angkatan. Preserve the existing authorization
behavior and avoid replacing the cast with another broad assertion.

Source: Linters/SAST tools

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
scratch/seed-clinical.ts (1)

119-122: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Type soals instead of suppressing no-explicit-any.

model Soal stores TipeSoal and Kesulitan, while soals is inferred with literal strings. Declare soals with the Prisma-generated SoalCreateInput type for the seed insert, then remove both as any casts and their suppression comments.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scratch/seed-clinical.ts` around lines 119 - 122, Type the soals seed data
using Prisma’s generated SoalCreateInput before the seed insert, then remove the
no-explicit-any suppression comments and both as any casts from tipe and
kesulitan while preserving their existing values.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@raw_admin.tsx`:
- Around line 55-61: Update the counts object so the value from
topikRepo.all().length is represented as a topic count rather than counts.modul,
and update the corresponding KPI display near the Modul Mata Kuliah label to use
the renamed topic count or derive the intended module/unit count instead. Keep
the other count fields unchanged.
- Line 156: Replace the corrupted “ΓÇó” separator with the intended bullet
separator in both subtitle strings: raw_admin.tsx lines 156-156 and 349-349.
Preserve the surrounding exam count and label text.
- Around line 40-41: Add a stateful time value in the component containing the
now calculation, and update it with a timer so exam categories are recomputed as
boundaries pass. Schedule refreshes around relevant exam start/end times, clean
up the timer on unmount or dependency changes, and use the refreshed state in
the active/upcoming/finished classification instead of the render-only
Date.now() value.

---

Nitpick comments:
In `@scratch/seed-clinical.ts`:
- Around line 119-122: Type the soals seed data using Prisma’s generated
SoalCreateInput before the seed insert, then remove the no-explicit-any
suppression comments and both as any casts from tipe and kesulitan while
preserving their existing values.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9efd40dc-c19a-4d51-81e5-733c446633e0

📥 Commits

Reviewing files that changed from the base of the PR and between 43c65ff and 1ca589d.

📒 Files selected for processing (8)
  • raw_admin.tsx
  • raw_admin.txt
  • scratch/seed-clinical.ts
  • skills-lock.json
  • src/lib/server/db/auth.ts
  • src/routes/_authenticated/admin.analitik.index.tsx
  • temp_admin.txt
  • ts_errors.txt
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/routes/_authenticated/admin.analitik.index.tsx
  • src/lib/server/db/auth.ts
📜 Review details
🔇 Additional comments (2)
skills-lock.json (2)

1-4: LGTM!

Also applies to: 9-10, 15-16, 21-22, 27-28, 33-34, 39-40, 45-47


5-8: 🔒 Security & Privacy

LLM Security (CWE-829): Inclusion of Functionality from Untrusted Control Sphere

Verify hash enforcement before loading these skills.

Each entry points to a SKILL.md instruction file that can be loaded into an agent session. (docs.github.com) The lock records computedHash, but this file does not prove that the loader verifies it.

Confirm that the loader:

  1. Hashes the fetched file and rejects mismatches.
  2. Does not silently refresh the content or hash.
  3. Requires review when a hash changes.

If the loader does not enforce this control, pin each skill to an immutable commit or add explicit verification.

#!/bin/bash
set -euo pipefail

# Expect: the loader verifies computedHash before loading skill content.
rg -n -C 6 \
  'skills-lock\.json|computedHash|skillPath|sourceType|skills (add|install|sync|update)' \
  --glob '!node_modules/**' .

Also applies to: 11-14, 17-20, 23-26, 29-32, 35-38, 41-44

Comment thread raw_admin.tsx Outdated
Comment on lines +40 to +41
const now = Date.now();
const ONE_WEEK = 7 * 24 * 60 * 60 * 1000;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files matching raw_admin.tsx:"
fd -a 'raw_admin\.tsx' . || true

file="$(fd 'raw_admin\.tsx' . | head -n 1 || true)"
if [ -n "${file:-}" ]; then
  echo "File: $file"
  wc -l "$file"
  echo "--- outline ---"
  ast-grep outline "$file" || true
  echo "--- relevant sections ---"
  sed -n '1,80p' "$file"
  echo "--- usages of now/exam state/ONE_WEEK ---"
  rg -n "now|ONE_WEEK|active|upcoming|finished|beginAt|endAt|setInterval|setTimeout|useEffect|useSchedule|useInterval|subscribe|reRender|sync" "$file" -C 3 || true
  echo "--- imports and parent usage ---"
  rg -n "raw_admin|RawAdmin|from ['\"].*raw_admin|RawAdminProps" . --glob '!node_modules' --glob '!dist' --glob '!build' -C 2 || true
fi

echo "--- repository/dash repo imports in file, if any ---"
if [ -n "${file:-}" ]; then
  sed -n '1,40p' "$file" | rg -n "import|from" || true
fi

Repository: mannnrachman/cbt-man

Length of output: 10375


Refresh time-dependent exam state.

now is computed only once during render, while the dashboard treats active/upcoming/finished categories from that fixed value. Add a stateful timer that refreshes around exam boundaries and clears on unmount so expired exams do not stay active and upcoming exams do not delay appearing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@raw_admin.tsx` around lines 40 - 41, Add a stateful time value in the
component containing the now calculation, and update it with a timer so exam
categories are recomputed as boundaries pass. Schedule refreshes around relevant
exam start/end times, clean up the timer on unmount or dependency changes, and
use the refreshed state in the active/upcoming/finished classification instead
of the render-only Date.now() value.

Comment thread raw_admin.tsx Outdated
Comment on lines +55 to +61
const counts = {
peserta: pesertaList.length,
modul: topikRepo.all().length,
soal: soalList.length,
ujian: semuaUjian.length,
sesi: sesiRepo.all().length,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not present a topic count as a module count.

Line 57 assigns topikRepo.all().length to counts.modul, but Line 172 displays it as Modul Mata Kuliah. Multiple topics can belong to one unit, so this KPI can overstate the module count.

Rename the KPI to topics, or derive the intended unit count.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@raw_admin.tsx` around lines 55 - 61, Update the counts object so the value
from topikRepo.all().length is represented as a topic count rather than
counts.modul, and update the corresponding KPI display near the Modul Mata
Kuliah label to use the renamed topic count or derive the intended module/unit
count instead. Keep the other count fields unchanged.

Comment thread raw_admin.tsx Outdated

@mannnrachman mannnrachman left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

@Jedijedai terima kasih untuk pekerjaan Bank Soal per Jurusan dan filter Angkatan. Saya sudah review head ba5a084b55ea2c9cd72cd53c05be6441f2ad0eb1.

Saya meminta perubahan, tetapi jangan rebase atau mencoba membersihkan PR aggregate ini di tempat. Branch ini membawa 31 commit / 39 file dan mewarisi seluruh lineage #83 (Redis, CheatSheets/calculator, snapshot, dashboard/sidebar lama), sehingga merge atau rebase biasa tetap berisiko membawa kembali kode yang sudah ditolak.

Mohon buat PR replacement baru dari main terbaru dengan ruang lingkup hanya Bank Soal per Jurusan + Angkatan, lalu referensikan #87 untuk kredit/riwayat kontribusi.

Checklist replacement PR

  1. Mulai bersih dari main

    git fetch origin main
    git switch -c feature/bank-soal-angkatan-v2 origin/main

    Jangan cherry-pick Redis, CheatSheets, snapshot peserta, dashboard/sidebar, atau penghapusan Modul/MataKuliah dari branch ini.

  2. Jaga struktur data yang sudah ada

    • Jangan menghapus/merombak Modul dan MataKuliah dalam feature ini.
    • Jika perlu User.angkatan, Ujian.angkatanIds, atau Ujian.isUmum, tambahkan migration Prisma additive dan data-preserving.
    • Jangan gunakan db push untuk menggantikan migration.
    • Jika desain benar-benar memerlukan migrasi Modul/MataKuliah → UnitAkademik, jadikan itu PR desain/migrasi khusus dengan backfill dan rencana rollback—bukan bagian dari feature UI ini.
  3. Satu aturan akses peserta di server

    • Tentukan dengan jelas arti array kosong (direkomendasikan: kosong = tidak membatasi / semua peserta).
    • Terapkan predicate yang sama untuk snapshot peserta, direct fetch ujian, pembuatan sesi/start exam, dan UI.
    • isUmum harus diizinkan server-side sebelum memeriksa restriction lama.
    • Ujian cohort/prodi hanya boleh terlihat dan dapat dibuka oleh peserta yang cocok.
  4. Selaraskan akses operator

    • Listing, direct fetch, editor, dan mutation harus memakai rule yang sama untuk ujian mixed-scope (gunakan seluruh topic set / every, bukan some).
  5. Perbaiki UX Bank Soal/import

    • Jika unit pertama kosong, selector unit tetap harus tampil agar operator dapat memilih unit lain yang memiliki topik.
    • Jangan menyimpan unitAkademikRepo.all() hanya saat mount; pastikan hasil hydration dapat terlihat.
    • Tidak boleh ada file scratch/, seed klinis sementara, atau artifact/temp file di replacement PR.
  6. Tambahkan test terfokus

    • migration deploy pada database baru/fixture yang merepresentasikan data lama;
    • peserta: umum, prodi-only, angkatan-only, gabungan prodi+angkatan, dan restriction kosong;
    • operator mixed-scope tidak dapat melihat/mengedit di luar cakupan;
    • import ketika unit pertama tidak punya topik tetapi unit lain punya topik.
  7. Validation sebelum minta review ulang

    npm run lint -- --max-warnings=7
    npm run typecheck
    npm run test:unit
    npm run build
    npx prisma validate
    npx prisma migrate deploy

Silakan biarkan branch ini tetap ada untuk referensi. Setelah replacement PR kecil dan CI hijau tersedia, mention saya di sana; saya akan review ulang dari head baru.

@mannnrachman

Copy link
Copy Markdown
Owner

@Jedijedai terima kasih untuk kontribusi Bank Soal per Jurusan dan filter Angkatan. Setelah review pada head ba5a084b55ea2c9cd72cd53c05be6441f2ad0eb1, PR ini ditutup tanpa merge agar main tetap bersih dan aman.

Fakta yang terverifikasi:

  • PR ini adalah aggregate 31 commit / 39 file dan juga membawa lineage Redis/calculator dari PR feat: Redis server functions & calculator UI revamp #83 yang sebelumnya sudah ditolak.
  • Ada perubahan schema besar (User.angkatan, akses ujian, serta arah Modul/MataKuliah ke UnitAkademik) tetapi tidak ada migration Prisma baru untuk memindahkan dan menjaga data produksi.
  • Aturan akses peserta tidak konsisten: UI/shared predicate memakai isUmum + prodi + angkatan, tetapi direct server fetch hanya memeriksa groupIds.
  • UI menyatakan restriction kosong berarti semua peserta, sedangkan predicate saat ini menolak ujian non-umum apabila kedua restriction kosong.
  • Operator dapat melihat/fetch ujian mixed-scope melalui rule some, tetapi editor/mutation memakai every.
  • Import dapat terkunci bila unit pertama tidak memiliki topik, dan terdapat scratch/seed-clinical.ts yang tidak cocok dengan schema baru.

Silakan buat PR replacement baru dari main terbaru yang hanya berisi Bank Soal/Angkatan, referensikan #87 untuk kredit kontribusi, dan sertakan: migration additive/data-preserving, satu aturan server-side untuk akses peserta, rule operator konsisten, perbaikan import/hydration, serta test migration dan authorization. Jangan bawa Redis, CheatSheets/calculator, dashboard/sidebar lama, package metadata, atau file scratch.

Branch feature/bank-soal-jurusan tetap dipertahankan untuk referensi. Terima kasih—fitur yang relevan tetap dapat masuk melalui PR kecil yang bersih dan mudah direview.

@Hzkun001
Hzkun001 deleted the feature/bank-soal-jurusan branch August 10, 2026 01:55
@Jedijedai
Jedijedai restored the feature/bank-soal-jurusan branch August 10, 2026 02:22
@mannnrachman mannnrachman mentioned this pull request Aug 10, 2026
16 of 26 tasks
@Hzkun001
Hzkun001 deleted the feature/bank-soal-jurusan branch August 21, 2026 11:09
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