Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions public/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,13 @@ <h2 class="text-xl font-bold text-slate-800 mb-5" id="section-title">My Activiti
document.getElementById('header-sub').textContent = 'Manage your hosted activities and track your participation.';
document.getElementById('section-title').textContent = 'Hosted Activities';

const stats = data.stats || {};
const totalParts = hosted.reduce((s,a) => s + (a.participant_count||0), 0);
document.getElementById('stat-cards').innerHTML =
statCard(hosted.length, 'Hosted', 'text-brand') +
statCard(totalParts, 'Participants', 'text-purple-600') +
statCard(joined.length, 'Joined', 'text-emerald-600') +
statCard('🔒', 'Encrypted', 'text-amber-500');
statCard(stats.hosted_count ?? hosted.length, 'Hosted', 'text-brand') +
statCard(stats.total_joined ?? joined.length, 'Joined', 'text-emerald-600') +
statCard(stats.completed ?? 0, 'Completed', 'text-blue-600') +
statCard(stats.total_sessions_attended ?? 0, 'Sessions Attended','text-purple-600');

document.getElementById('quick-actions').innerHTML =
'<div class="flex flex-wrap gap-3">' +
Expand Down Expand Up @@ -150,6 +151,11 @@ <h2 class="text-xl font-bold text-slate-800 mb-5" id="section-title">My Activiti
const rc = roleColor[a.enr_role] || 'bg-slate-100 text-slate-600';
const sc = statusColor[a.enr_status] || 'bg-slate-100 text-slate-600';
const tags = (a.tags||[]).slice(0,3).map(t => '<span class="badge bg-slate-100 text-slate-500">' + esc(t) + '</span>').join('');
const pct = a.progress_pct ?? 0;
const progressBar = a.total_sessions > 0
? '<div class="mt-1"><div class="flex justify-between text-xs text-slate-400 mb-1"><span>Progress</span><span>' + a.attended_sessions + '/' + a.total_sessions + ' sessions</span></div>' +
'<div class="w-full bg-slate-100 rounded-full h-2" role="progressbar" aria-valuenow="' + pct + '" aria-valuemin="0" aria-valuemax="100" aria-label="Session attendance progress"><div class="bg-indigo-500 h-2 rounded-full transition-all" style="width:' + pct + '%"></div></div></div>'
: '<p class="text-xs text-slate-400">No sessions scheduled yet</p>';
return '<article class="bg-white rounded-2xl shadow-sm border border-slate-100 p-5 card-hover flex flex-col gap-3">' +
'<div class="flex items-start justify-between gap-2">' +
'<h3 class="font-bold text-slate-800 text-sm">' + ic + ' ' + esc(a.title) + '</h3>' +
Expand All @@ -161,6 +167,7 @@ <h2 class="text-xl font-bold text-slate-800 mb-5" id="section-title">My Activiti
'<span class="badge ' + sc + '">' + esc(a.enr_status) + '</span>' +
'<span class="text-xs text-slate-400">' + (fmtLabel[a.format]||a.format) + '</span>' +
'</div>' +
progressBar +
'<div class="flex flex-wrap gap-1">' + tags + '</div>' +
'<a href="/course.html?id=' + esc(a.id) + '" class="mt-auto block text-center text-sm font-semibold text-brand border border-brand/30 rounded-lg py-1.5 hover:bg-indigo-50">View Activity</a>' +
'</article>';
Expand Down
153 changes: 108 additions & 45 deletions src/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
POST /api/activities – create activity [host]
GET /api/activities/:id – activity + sessions + state
POST /api/join – join an activity
GET /api/dashboard – personal dashboard
GET /api/dashboard – personal dashboard with progress stats
POST /api/attendance – mark session attendance [auth]
POST /api/sessions – add a session to activity [host]
GET /api/tags – list all tags
POST /api/activity-tags – add tags to an activity [host]
Expand Down Expand Up @@ -993,66 +994,88 @@ async def api_dashboard(req, env):
user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET)
if not user:
return err("Authentication required", 401)

enc = env.ENCRYPTION_KEY

# Hosted activities
res = await env.DB.prepare(
"SELECT a.id,a.title,a.type,a.format,a.schedule_type,a.created_at,"
"(SELECT COUNT(*) FROM enrollments WHERE activity_id=a.id AND status='active')"
" AS participant_count,"
"SELECT a.id, a.title, a.type, a.format, a.schedule_type, a.created_at,"
"(SELECT COUNT(*) FROM enrollments WHERE activity_id=a.id AND status='active') AS participant_count,"
"(SELECT COUNT(*) FROM sessions WHERE activity_id=a.id) AS session_count"
" FROM activities a WHERE a.host_id=? ORDER BY a.created_at DESC"
).bind(user["id"]).all()
hosted_rows = res.results or []
hosted_ids = [r["id"] for r in hosted_rows]
hosted_tags = {}
if hosted_ids:
placeholders = ",".join("?" * len(hosted_ids))
tag_res = await env.DB.prepare(
f"SELECT at2.activity_id, t.name FROM tags t JOIN activity_tags at2 ON at2.tag_id=t.id"
f" WHERE at2.activity_id IN ({placeholders})"
).bind(*hosted_ids).all()
for tr in (tag_res.results or []):
hosted_tags.setdefault(tr["activity_id"], []).append(tr["name"])
hosted = [
{
"id": r["id"], "title": r["title"], "type": r["type"],
"format": r["format"], "schedule_type": r["schedule_type"],
"participant_count": r["participant_count"] or 0,
"session_count": r["session_count"] or 0,
"tags": hosted_tags.get(r["id"], []),
"created_at": r["created_at"],
}
for r in hosted_rows
]

hosted = []
for r in res.results or []:
t_res = await env.DB.prepare(
"SELECT t.name FROM tags t JOIN activity_tags at2 ON at2.tag_id=t.id"
" WHERE at2.activity_id=?"
).bind(r.id).all()
hosted.append({
"id": r.id,
"title": r.title,
"type": r.type,
"format": r.format,
"schedule_type": r.schedule_type,
"participant_count": r.participant_count,
"session_count": r.session_count,
"tags": [t.name for t in (t_res.results or [])],
"created_at": r.created_at,
})

# Joined activities with progress
res2 = await env.DB.prepare(
"SELECT a.id,a.title,a.type,a.format,a.schedule_type,"
"e.role AS enr_role,e.status AS enr_status,e.created_at AS joined_at,"
"u.name AS host_name_enc"
"SELECT a.id, a.title, a.type, a.format, a.schedule_type,"
" e.role AS enr_role, e.status AS enr_status, e.created_at AS joined_at,"
" u.name AS host_name_enc,"
" (SELECT COUNT(*) FROM sessions WHERE activity_id=a.id) AS total_sessions,"
" (SELECT COUNT(*) FROM session_attendance sa"
" JOIN sessions s ON s.id=sa.session_id"
" WHERE s.activity_id=a.id AND sa.user_id=? AND sa.status='attended') AS attended_sessions"
" FROM enrollments e"
" JOIN activities a ON e.activity_id=a.id"
" JOIN users u ON a.host_id=u.id"
" WHERE e.user_id=? ORDER BY e.created_at DESC"
).bind(user["id"]).all()

).bind(user["id"], user["id"]).all()
joined_rows = res2.results or []
joined_ids = [r["id"] for r in joined_rows]
joined_tags = {}
if joined_ids:
placeholders2 = ",".join("?" * len(joined_ids))
tag_res2 = await env.DB.prepare(
f"SELECT at2.activity_id, t.name FROM tags t JOIN activity_tags at2 ON at2.tag_id=t.id"
f" WHERE at2.activity_id IN ({placeholders2})"
).bind(*joined_ids).all()
for tr in (tag_res2.results or []):
joined_tags.setdefault(tr["activity_id"], []).append(tr["name"])
joined = []
for r in res2.results or []:
t_res = await env.DB.prepare(
"SELECT t.name FROM tags t JOIN activity_tags at2 ON at2.tag_id=t.id"
" WHERE at2.activity_id=?"
).bind(r.id).all()
for r in joined_rows:
total = r["total_sessions"] or 0
attended = r["attended_sessions"] or 0
progress = round((attended / total) * 100) if total > 0 else 0
joined.append({
"id": r.id,
"title": r.title,
"type": r.type,
"format": r.format,
"schedule_type": r.schedule_type,
"enr_role": r.enr_role,
"enr_status": r.enr_status,
"host_name": await decrypt_aes(r.host_name_enc or "", enc),
"tags": [t.name for t in (t_res.results or [])],
"joined_at": r.joined_at,
"id": r["id"], "title": r["title"], "type": r["type"],
"format": r["format"], "schedule_type": r["schedule_type"],
"enr_role": r["enr_role"], "enr_status": r["enr_status"],
"host_name": decrypt(r["host_name_enc"] or "", enc),
"tags": joined_tags.get(r["id"], []),
"joined_at": r["joined_at"],
"total_sessions": total,
"attended_sessions": attended,
"progress_pct": progress,
Comment on lines 1059 to +1068
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Switch host_name back to the async decrypt helper.

Line 1063 calls the deprecated sync decrypt() shim, which raises RuntimeError by design. Any user with at least one joined activity will get a 500 from GET /api/dashboard.

🐛 Proposed fix
         joined.append({
             "id": r["id"], "title": r["title"], "type": r["type"],
             "format": r["format"], "schedule_type": r["schedule_type"],
             "enr_role": r["enr_role"], "enr_status": r["enr_status"],
-            "host_name": decrypt(r["host_name_enc"] or "", enc),
+            "host_name": await decrypt_aes(r["host_name_enc"] or "", enc),
             "tags": joined_tags.get(r["id"], []),
             "joined_at": r["joined_at"],
             "total_sessions": total,
             "attended_sessions": attended,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/worker.py` around lines 1059 - 1068, The code is calling the deprecated
sync shim decrypt(...) which raises RuntimeError; replace that call with the
async decrypt helper (e.g., await decrypt_async(...) or await decrypt(... ) if
the async function is named decrypt) when building the "host_name" field so the
operation is awaited and non-blocking; update the surrounding function (the
generator/handler that builds joined) to be async or perform an asyncio.gather
over multiple decrypt calls so the list comprehension/loop that produces joined
can await the async helper for r["host_name_enc"] and pass the enc parameter
correctly.

})

return json_resp({"user": user, "hosted_activities": hosted, "joined_activities": joined})

stats = {
"total_joined": len(joined),
"completed": sum(1 for a in joined if a["enr_status"] == "completed"),
"in_progress": sum(1 for a in joined if a["enr_status"] == "active" and a["total_sessions"] > 0),
"total_sessions_attended": sum(a["attended_sessions"] for a in joined),
"hosted_count": len(hosted),
}
return json_resp({"user": user, "hosted_activities": hosted, "joined_activities": joined, "stats": stats})
Comment on lines +999 to +1078
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Add regression coverage for the new dashboard and attendance flows.

This PR changes /api/dashboard aggregation and adds enrollment-gated attendance upserts, but there’s no test coverage in the diff for zero-session activities, non-enrolled users, default "registered", or re-marking the same session. A small test matrix here would catch regressions like the Line 1063 failure quickly.

As per coding guidelines: "Verify tests cover the key logic paths."

Also applies to: 1325-1359

🧰 Tools
🪛 Ruff (0.15.7)

[error] 1012-1013: Possible SQL injection vector through string-based query construction

(S608)


[error] 1049-1050: Possible SQL injection vector through string-based query construction

(S608)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/worker.py` around lines 999 - 1078, Add tests that cover the new
/api/dashboard aggregation and the enrollment-gated attendance upsert flows:
create fixtures for activities with zero sessions and with >0 sessions, users
who are enrolled (enr_status values including default "registered" and "active")
and non-enrolled users, and exercise re-marking the same session twice. For the
dashboard endpoint assert hosted and joined payload shapes (hosted, joined
lists), joined[*].progress_pct, joined[*].attended_sessions, stats keys
(total_joined, completed, in_progress, total_sessions_attended, hosted_count)
and that zero-session activities yield progress_pct 0 and don’t inflate
in_progress; for attendance upserts simulate attendance calls for an enrolled vs
non-enrolled user and re-marking the same session to ensure attendance
increments only when allowed and idempotency holds. Locate tests to cover the
code paths populating joined, hosted, stats and the enrollment-gated attendance
upsert logic referenced in the PR.


async def api_create_session(req, env):
user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET)
Expand Down Expand Up @@ -1276,6 +1299,8 @@ async def _dispatch(request, env):
if path == "/api/join" and method == "POST":
return await api_join(request, env)

if path == "/api/attendance" and method == "POST":
return await api_mark_attendance(request, env)
if path == "/api/dashboard" and method == "GET":
return await api_dashboard(request, env)

Expand All @@ -1296,6 +1321,44 @@ async def _dispatch(request, env):
return await serve_static(path, env)



async def api_mark_attendance(req, env):
"""POST /api/attendance — mark session attendance for current user."""
user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET)
if not user:
return err("Authentication required", 401)
body, bad = await parse_json_object(req)
if bad:
return bad
session_id = (body.get("session_id") or "").strip()
status = body.get("status", "registered")
if not session_id:
return err("session_id is required")
if status not in ("registered", "attended", "missed"):
return err("status must be registered, attended, or missed")
# Verify session exists and user is enrolled in that activity
sess = await env.DB.prepare(
"SELECT s.id, s.activity_id FROM sessions s WHERE s.id = ?"
).bind(session_id).first()
if not sess:
return err("Session not found", 404)
enr = await env.DB.prepare(
"SELECT id FROM enrollments WHERE activity_id = ? AND user_id = ? AND status = 'active'"
).bind(sess["activity_id"], user["id"]).first()
if not enr:
return err("You must be enrolled in this activity", 403)
# Upsert attendance
try:
await env.DB.prepare(
"INSERT INTO session_attendance (id, session_id, user_id, status) VALUES (?, ?, ?, ?)"
" ON CONFLICT(session_id, user_id) DO UPDATE SET status = excluded.status"
).bind(new_id(), session_id, user["id"], status).run()
except Exception as exc:
capture_exception(exc, req, env, where="api_mark_attendance")
return err("Failed to record attendance", 500)
return ok({"session_id": session_id, "status": status}, "Attendance recorded")


async def on_fetch(request, env):
try:
return await _dispatch(request, env)
Expand Down