From 3a7150bf6c0c6fea2e659cd9d68d8d1a6b9d64bf Mon Sep 17 00:00:00 2001 From: ayesha1145 Date: Mon, 20 Apr 2026 12:19:22 -0500 Subject: [PATCH 1/6] feat: add notifications system with unread count, mark read, and mark all read --- schema.sql | 15 +++++ src/worker.py | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/schema.sql b/schema.sql index 2aa67ff..34e76e5 100644 --- a/schema.sql +++ b/schema.sql @@ -93,3 +93,18 @@ CREATE INDEX IF NOT EXISTS idx_sessions_activity ON sessions(activity_id); CREATE INDEX IF NOT EXISTS idx_sa_session ON session_attendance(session_id); CREATE INDEX IF NOT EXISTS idx_sa_user ON session_attendance(user_id); CREATE INDEX IF NOT EXISTS idx_at_activity ON activity_tags(activity_id); + +-- NOTIFICATIONS +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + type TEXT NOT NULL, + title TEXT NOT NULL, + message TEXT NOT NULL, + is_read INTEGER NOT NULL DEFAULT 0, + related_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_notif_user ON notifications(user_id); +CREATE INDEX IF NOT EXISTS idx_notif_unread ON notifications(user_id, is_read); diff --git a/src/worker.py b/src/worker.py index 49e3951..7f5eba3 100644 --- a/src/worker.py +++ b/src/worker.py @@ -524,6 +524,21 @@ def _is_basic_auth_valid(req, env) -> bool: "CREATE INDEX IF NOT EXISTS idx_sa_session ON session_attendance(session_id)", "CREATE INDEX IF NOT EXISTS idx_sa_user ON session_attendance(user_id)", "CREATE INDEX IF NOT EXISTS idx_at_activity ON activity_tags(activity_id)", + # Notifications + """CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + type TEXT NOT NULL, + title TEXT NOT NULL, + message TEXT NOT NULL, + is_read INTEGER NOT NULL DEFAULT 0, + related_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )""", + "CREATE INDEX IF NOT EXISTS idx_notif_user ON notifications(user_id)", + "CREATE INDEX IF NOT EXISTS idx_notif_unread ON notifications(user_id, is_read)", + ] @@ -1404,6 +1419,18 @@ async def _dispatch(request, env): await capture_exception(exc, request, env, "api_error_test") return ok(None, "Test error sent to Sentry v2") + + # Notifications + if path == "/api/notifications" and method == "GET": + return await api_list_notifications(request, env) + if path == "/api/notifications/unread-count" and method == "GET": + return await api_unread_count(request, env) + m_notif_read = re.fullmatch(r"/api/notifications/([A-Za-z0-9_-]+)/read", path) + if m_notif_read and method == "POST": + return await api_mark_notification_read(request, env, m_notif_read.group(1)) + if path == "/api/notifications/read-all" and method == "POST": + return await api_mark_all_read(request, env) + return err("API endpoint not found", 404) return await serve_static(path, env) @@ -1416,3 +1443,125 @@ async def on_fetch(request, env): except Exception as e: await capture_exception(e, request, env, "on_fetch_unhandled") return err("Internal server error", 500) + + +# --------------------------------------------------------------------------- +# Notifications API +# --------------------------------------------------------------------------- + +async def _create_notification(env, user_id: str, type_: str, title: str, + message: str, related_id: str = None) -> None: + """Internal helper called by other handlers to create a notification. + + Silently swallows errors so a notification failure never breaks the + parent operation (e.g. grading, peer requests, new assignments). + """ + try: + await env.DB.prepare( + "INSERT INTO notifications (id, user_id, type, title, message, related_id)" + " VALUES (?, ?, ?, ?, ?, ?)" + ).bind(new_id(), user_id, type_, title, message, related_id).run() + except Exception as exc: + capture_exception(exc, where="_create_notification") + + +async def api_list_notifications(req, env): + """GET /api/notifications — list notifications for the authenticated user. + + Query params: + - unread_only=true return only unread notifications (default: false) + - limit=N max results, default 20, max 50 + """ + user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + url = req.url + unread_only = "unread_only=true" in url + try: + raw_limit = int(url.split("limit=")[1].split("&")[0]) if "limit=" in url else 20 + limit = max(1, min(raw_limit, 50)) + except (ValueError, IndexError): + limit = 20 + + if unread_only: + rows = await env.DB.prepare( + "SELECT id, type, title, message, is_read, related_id, created_at" + " FROM notifications" + " WHERE user_id = ? AND is_read = 0" + " ORDER BY created_at DESC LIMIT ?" + ).bind(user["id"], limit).all() + else: + rows = await env.DB.prepare( + "SELECT id, type, title, message, is_read, related_id, created_at" + " FROM notifications" + " WHERE user_id = ?" + " ORDER BY created_at DESC LIMIT ?" + ).bind(user["id"], limit).all() + + notifications = [] + for r in rows.results or []: + notifications.append({ + "id": r["id"], + "type": r["type"], + "title": r["title"], + "message": r["message"], + "is_read": bool(r["is_read"]), + "related_id": r["related_id"], + "created_at": r["created_at"], + }) + + unread_count = await env.DB.prepare( + "SELECT COUNT(*) AS cnt FROM notifications WHERE user_id = ? AND is_read = 0" + ).bind(user["id"]).first() + + return ok({ + "notifications": notifications, + "unread_count": unread_count["cnt"] if unread_count else 0, + }) + + +async def api_unread_count(req, env): + """GET /api/notifications/unread-count — return unread badge count only.""" + user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + row = await env.DB.prepare( + "SELECT COUNT(*) AS cnt FROM notifications WHERE user_id = ? AND is_read = 0" + ).bind(user["id"]).first() + + return ok({"unread_count": row["cnt"] if row else 0}) + + +async def api_mark_notification_read(req, env, notification_id: str): + """POST /api/notifications/:id/read — mark a single notification as read.""" + user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + notif = await env.DB.prepare( + "SELECT id FROM notifications WHERE id = ? AND user_id = ?" + ).bind(notification_id, user["id"]).first() + + if not notif: + return err("Notification not found", 404) + + await env.DB.prepare( + "UPDATE notifications SET is_read = 1 WHERE id = ?" + ).bind(notification_id).run() + + return ok(msg="Notification marked as read") + + +async def api_mark_all_read(req, env): + """POST /api/notifications/read-all — mark all notifications as read.""" + user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + await env.DB.prepare( + "UPDATE notifications SET is_read = 1 WHERE user_id = ? AND is_read = 0" + ).bind(user["id"]).run() + + return ok(msg="All notifications marked as read") From a9e5f5b459088a5acb1bc42d86c210be0cbf9074 Mon Sep 17 00:00:00 2001 From: ayesha1145 Date: Mon, 20 Apr 2026 14:33:25 -0500 Subject: [PATCH 2/6] fix: await capture_exception, use parse_qs, attribute access, type hints --- src/worker.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/worker.py b/src/worker.py index 7f5eba3..c9a1d51 100644 --- a/src/worker.py +++ b/src/worker.py @@ -1450,7 +1450,7 @@ async def on_fetch(request, env): # --------------------------------------------------------------------------- async def _create_notification(env, user_id: str, type_: str, title: str, - message: str, related_id: str = None) -> None: + message: str, related_id: str | None = None) -> None: """Internal helper called by other handlers to create a notification. Silently swallows errors so a notification failure never breaks the @@ -1462,7 +1462,7 @@ async def _create_notification(env, user_id: str, type_: str, title: str, " VALUES (?, ?, ?, ?, ?, ?)" ).bind(new_id(), user_id, type_, title, message, related_id).run() except Exception as exc: - capture_exception(exc, where="_create_notification") + await capture_exception(exc, env=env, where="_create_notification") async def api_list_notifications(req, env): @@ -1531,7 +1531,7 @@ async def api_unread_count(req, env): "SELECT COUNT(*) AS cnt FROM notifications WHERE user_id = ? AND is_read = 0" ).bind(user["id"]).first() - return ok({"unread_count": row["cnt"] if row else 0}) + return ok({"unread_count": row.cnt if row else 0}) async def api_mark_notification_read(req, env, notification_id: str): From 01087cbc5c462d789d5b9c721e1109f66c32a4f4 Mon Sep 17 00:00:00 2001 From: ayesha1145 Date: Mon, 20 Apr 2026 14:42:37 -0500 Subject: [PATCH 3/6] fix: add idx_notif_created index for ORDER BY created_at DESC queries --- schema.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/schema.sql b/schema.sql index 34e76e5..319ae3a 100644 --- a/schema.sql +++ b/schema.sql @@ -107,4 +107,5 @@ CREATE TABLE IF NOT EXISTS notifications ( FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_notif_user ON notifications(user_id); -CREATE INDEX IF NOT EXISTS idx_notif_unread ON notifications(user_id, is_read); +CREATE INDEX IF NOT EXISTS idx_notif_unread ON notifications(user_id, is_read); +CREATE INDEX IF NOT EXISTS idx_notif_created ON notifications(user_id, created_at DESC); From ce588bb9b326741a04914063f5fa6c4ae994057e Mon Sep 17 00:00:00 2001 From: ayesha1145 Date: Mon, 20 Apr 2026 14:59:30 -0500 Subject: [PATCH 4/6] fix: use attribute access for D1 rows and list comprehension in notifications --- src/worker.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/worker.py b/src/worker.py index c9a1d51..9ab424f 100644 --- a/src/worker.py +++ b/src/worker.py @@ -1499,17 +1499,18 @@ async def api_list_notifications(req, env): " ORDER BY created_at DESC LIMIT ?" ).bind(user["id"], limit).all() - notifications = [] - for r in rows.results or []: - notifications.append({ - "id": r["id"], - "type": r["type"], - "title": r["title"], - "message": r["message"], - "is_read": bool(r["is_read"]), - "related_id": r["related_id"], - "created_at": r["created_at"], - }) + notifications = [ + { + "id": r.id, + "type": r.type, + "title": r.title, + "message": r.message, + "is_read": bool(r.is_read), + "related_id": r.related_id, + "created_at": r.created_at, + } + for r in rows.results or [] + ] unread_count = await env.DB.prepare( "SELECT COUNT(*) AS cnt FROM notifications WHERE user_id = ? AND is_read = 0" @@ -1517,7 +1518,7 @@ async def api_list_notifications(req, env): return ok({ "notifications": notifications, - "unread_count": unread_count["cnt"] if unread_count else 0, + "unread_count": unread_count.cnt if unread_count else 0, }) From ac0d4a8d30392998830c120a248e332831bdee5a Mon Sep 17 00:00:00 2001 From: ayesha1145 Date: Wed, 22 Apr 2026 10:44:41 -0500 Subject: [PATCH 5/6] fix: encrypt title and message fields in notifications using encrypt/decrypt --- src/worker.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/worker.py b/src/worker.py index 9ab424f..e8c3e79 100644 --- a/src/worker.py +++ b/src/worker.py @@ -1457,10 +1457,13 @@ async def _create_notification(env, user_id: str, type_: str, title: str, parent operation (e.g. grading, peer requests, new assignments). """ try: + enc = env.ENCRYPTION_KEY await env.DB.prepare( "INSERT INTO notifications (id, user_id, type, title, message, related_id)" " VALUES (?, ?, ?, ?, ?, ?)" - ).bind(new_id(), user_id, type_, title, message, related_id).run() + ).bind(new_id(), user_id, type_, + encrypt(title, enc), encrypt(message, enc), + related_id).run() except Exception as exc: await capture_exception(exc, env=env, where="_create_notification") @@ -1503,8 +1506,8 @@ async def api_list_notifications(req, env): { "id": r.id, "type": r.type, - "title": r.title, - "message": r.message, + "title": decrypt(r.title or "", env.ENCRYPTION_KEY), + "message": decrypt(r.message or "", env.ENCRYPTION_KEY), "is_read": bool(r.is_read), "related_id": r.related_id, "created_at": r.created_at, From 874523013fa109eec0a3afdebf31f99cae47756a Mon Sep 17 00:00:00 2001 From: ayesha1145 Date: Wed, 22 Apr 2026 11:08:51 -0500 Subject: [PATCH 6/6] fix: add idx_notif_created to Worker DDL to match schema.sql --- src/worker.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/worker.py b/src/worker.py index e8c3e79..c2e8ebb 100644 --- a/src/worker.py +++ b/src/worker.py @@ -537,7 +537,8 @@ def _is_basic_auth_valid(req, env) -> bool: FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )""", "CREATE INDEX IF NOT EXISTS idx_notif_user ON notifications(user_id)", - "CREATE INDEX IF NOT EXISTS idx_notif_unread ON notifications(user_id, is_read)", + "CREATE INDEX IF NOT EXISTS idx_notif_unread ON notifications(user_id, is_read)", + "CREATE INDEX IF NOT EXISTS idx_notif_created ON notifications(user_id, created_at DESC)", ]