diff --git a/migrations/0010_add_surveys.sql b/migrations/0010_add_surveys.sql
new file mode 100644
index 0000000..8c9a485
--- /dev/null
+++ b/migrations/0010_add_surveys.sql
@@ -0,0 +1,64 @@
+-- Migration 0010: Add survey system
+--
+-- Survey content (title/description/questions/options) is treated as
+-- public-facing data (not PII), so it is stored in plaintext to support
+-- server-side search and CSV export, unlike encrypted activity fields.
+
+CREATE TABLE IF NOT EXISTS surveys (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ title TEXT NOT NULL,
+ description TEXT,
+ is_public INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS survey_questions (
+ id TEXT PRIMARY KEY,
+ survey_id TEXT NOT NULL,
+ question_text TEXT NOT NULL,
+ question_type TEXT NOT NULL CHECK (
+ question_type IN ('multiple_choice', 'checkbox', 'text', 'true_false', 'scale')
+ ),
+ required INTEGER NOT NULL DEFAULT 0,
+ display_order INTEGER NOT NULL DEFAULT 0,
+ FOREIGN KEY (survey_id) REFERENCES surveys(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS survey_options (
+ id TEXT PRIMARY KEY,
+ question_id TEXT NOT NULL,
+ option_text TEXT NOT NULL,
+ display_order INTEGER NOT NULL DEFAULT 0,
+ FOREIGN KEY (question_id) REFERENCES survey_questions(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS survey_responses (
+ id TEXT PRIMARY KEY,
+ survey_id TEXT NOT NULL,
+ user_id TEXT,
+ submitted_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE (survey_id, user_id),
+ FOREIGN KEY (survey_id) REFERENCES surveys(id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS survey_answers (
+ id TEXT PRIMARY KEY,
+ response_id TEXT NOT NULL,
+ question_id TEXT NOT NULL,
+ answer_text TEXT,
+ FOREIGN KEY (response_id) REFERENCES survey_responses(id) ON DELETE CASCADE,
+ FOREIGN KEY (question_id) REFERENCES survey_questions(id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_surveys_user ON surveys(user_id);
+CREATE INDEX IF NOT EXISTS idx_surveys_public ON surveys(is_public);
+CREATE INDEX IF NOT EXISTS idx_survey_questions_survey ON survey_questions(survey_id);
+CREATE INDEX IF NOT EXISTS idx_survey_options_question ON survey_options(question_id);
+CREATE INDEX IF NOT EXISTS idx_survey_responses_survey ON survey_responses(survey_id);
+CREATE INDEX IF NOT EXISTS idx_survey_responses_user ON survey_responses(user_id);
+CREATE INDEX IF NOT EXISTS idx_survey_answers_response ON survey_answers(response_id);
+CREATE INDEX IF NOT EXISTS idx_survey_answers_question ON survey_answers(question_id);
diff --git a/public/partials/navbar.html b/public/partials/navbar.html
index acaf410..292681f 100644
--- a/public/partials/navbar.html
+++ b/public/partials/navbar.html
@@ -66,7 +66,7 @@
Learning Requests
-
+
Surveys
@@ -278,6 +278,9 @@
Courses
+
+ Surveys
+
diff --git a/public/survey-create.html b/public/survey-create.html
new file mode 100644
index 0000000..1171afa
--- /dev/null
+++ b/public/survey-create.html
@@ -0,0 +1,480 @@
+
+
+
+
+
+ Create Survey - Alpha One Labs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Surveys
+
+
+
Build a survey with multiple question types. Your draft is saved automatically.
+
+
+
+
+
+
+
+
You need to log in to create a survey. Your draft will be kept.
+
+
+
+
+ Survey Title
+
+
+
+ Description
+
+
+
+
+
Public Survey
+
Public surveys appear in the directory and can be taken by anyone.
+
+
+
+
+
+
+
+
+
+
Questions
+ 0 / 50
+
+
+
+
+
+
+ No questions yet. Add your first question below.
+
+
+
+ Add Question
+
+
+
+
+
+ Clear Draft
+
+
+ Publish Survey
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Clear draft?
+
This cannot be undone.
+
+
+
+ Cancel
+ Clear Draft
+
+
+
+
+
+
+
+
diff --git a/public/survey-results.html b/public/survey-results.html
new file mode 100644
index 0000000..6ac61ed
--- /dev/null
+++ b/public/survey-results.html
@@ -0,0 +1,362 @@
+
+
+
+
+
+ Survey Results - Alpha One Labs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
These results could not be loaded.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No responses yet. Share this survey to start collecting data.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/survey.html b/public/survey.html
new file mode 100644
index 0000000..68c2d94
--- /dev/null
+++ b/public/survey.html
@@ -0,0 +1,324 @@
+
+
+
+
+
+ Survey - Alpha One Labs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
This survey could not be loaded.
+
+
+
+
+
+
🎉
+
Thanks for your response!
+
Your answers have been recorded.
+
+
+
+
+
✅
+
You've already responded
+
Each participant can only submit one response per survey.
+
+ View Results
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/surveys.html b/public/surveys.html
new file mode 100644
index 0000000..a97241e
--- /dev/null
+++ b/public/surveys.html
@@ -0,0 +1,276 @@
+
+
+
+
+
+ All Surveys - Alpha One Labs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Load more
+
+
+
+
+
+
+
+
Failed to load surveys. Please try again.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Delete survey?
+
This cannot be undone.
+
+
+
+ Cancel
+ Delete
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/api/surveys.py b/src/api/surveys.py
new file mode 100644
index 0000000..8babb94
--- /dev/null
+++ b/src/api/surveys.py
@@ -0,0 +1,115 @@
+"""
+Survey system — HTTP handlers.
+
+Thin request/response layer over ``surveys.py``: parses auth headers, query
+params and JSON bodies, then delegates to the data-access functions and
+wraps the result in the project's standard response envelope.
+"""
+
+import surveys as core
+
+
+def _auth_user(req, env):
+ return core.verify_token(req.headers.get("Authorization") or "", env.JWT_SECRET)
+
+
+async def api_list_surveys(req, env):
+ user = _auth_user(req, env)
+ params = core.get_query_params(req)
+ search = (params.get("q") or [""])[0].strip()
+ limit = core.query_int(params, "limit", 20, 1, 50)
+ offset = core.query_int(params, "offset", 0, 0, 10_000)
+
+ surveys = await core.list_surveys(env, search, limit, offset, user["id"] if user else None)
+ return core.json_resp({"surveys": surveys, "limit": limit, "offset": offset})
+
+
+async def api_create_survey(req, env):
+ user = _auth_user(req, env)
+ if not user:
+ return core.err("Authentication required", 401)
+
+ body, bad_resp = await core.parse_json_object(req)
+ if bad_resp:
+ return bad_resp
+
+ cleaned, error = core.validate_survey_payload(body)
+ if error:
+ return core.err(error)
+
+ survey_id = await core.create_survey(env, user["id"], cleaned)
+ return core.ok({"id": survey_id, "title": cleaned["title"]}, "Survey created")
+
+
+async def api_get_survey(survey_id, req, env):
+ user = _auth_user(req, env)
+ survey = await core.get_survey(env, survey_id, user["id"] if user else None)
+ if not survey:
+ return core.err("Survey not found", 404)
+ return core.json_resp({"survey": survey})
+
+
+async def api_submit_survey_response(survey_id, req, env):
+ user = _auth_user(req, env)
+
+ body, bad_resp = await core.parse_json_object(req)
+ if bad_resp:
+ return bad_resp
+
+ answers = body.get("answers")
+ if not isinstance(answers, dict):
+ return core.err("answers must be an object mapping question_id to a value")
+
+ response_id, error = await core.submit_response(
+ env, survey_id, user["id"] if user else None, answers
+ )
+ if error:
+ if error == "already_submitted":
+ return core.err(
+ "You have already submitted a response to this survey",
+ 400,
+ code="already_submitted",
+ )
+ status = 404 if error == "Survey not found" else 400
+ return core.err(error, status)
+
+ return core.ok({"response_id": response_id}, "Response submitted")
+
+
+async def api_get_survey_results(survey_id, req, env):
+ user = _auth_user(req, env)
+ results, error = await core.get_results(env, survey_id, user["id"] if user else None)
+ if error:
+ return core.err(error, 404)
+ return core.json_resp({"results": results})
+
+
+async def api_delete_survey(survey_id, req, env):
+ user = _auth_user(req, env)
+ if not user:
+ return core.err("Authentication required", 401)
+
+ success, error = await core.delete_survey(env, survey_id, user["id"])
+ if not success:
+ status = 404 if error == "Survey not found" else 403
+ return core.err(error, status)
+
+ return core.ok(None, "Survey deleted")
+
+
+async def api_export_survey(survey_id, req, env):
+ user = _auth_user(req, env)
+ csv_text, error = await core.export_csv(env, survey_id, user["id"] if user else None)
+ if error:
+ return core.err(error, 404)
+
+ from workers import Response
+ return Response(
+ csv_text,
+ status=200,
+ headers={
+ "Content-Type": "text/csv",
+ "Content-Disposition": f'attachment; filename="survey-{survey_id}-export.csv"',
+ **core.CORS_HEADERS,
+ },
+ )
\ No newline at end of file
diff --git a/src/surveys.py b/src/surveys.py
new file mode 100644
index 0000000..e54a688
--- /dev/null
+++ b/src/surveys.py
@@ -0,0 +1,654 @@
+"""
+Survey system — core helpers and D1 data-access layer.
+
+This module is intentionally self-contained (it does not import from
+``worker``) so that ``worker.py`` can import it without creating a circular
+dependency. The handful of low-level helpers duplicated here (``new_id``,
+``verify_token``, the response envelope helpers) are exact copies of the
+ones in ``worker.py`` — they are pure, stateless functions so the
+duplication carries no risk of behavioural drift.
+
+D1 does not enforce ``PRAGMA foreign_keys = ON`` by default, so cascading
+deletes are performed explicitly in ``delete_survey`` rather than relying on
+the ``ON DELETE CASCADE`` clauses declared in the migration.
+"""
+
+import base64
+import csv
+import hashlib
+import hmac as _hmac
+import io
+import json
+import os
+from urllib.parse import parse_qs, urlparse
+
+from workers import Response
+
+QUESTION_TYPES = {"multiple_choice", "checkbox", "text", "true_false", "scale"}
+OPTION_QUESTION_TYPES = {"multiple_choice", "checkbox"}
+MAX_QUESTIONS = 50
+MAX_TITLE_LEN = 200
+
+# Separator used to pack multiple selected checkbox options into a single
+# ``survey_answers.answer_text`` column.
+CHECKBOX_SEP = "␟"
+
+# Characters that Excel/Sheets treat as formula triggers when a cell value
+# starts with them (CSV/formula injection, CWE-1236).
+_CSV_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r", "\n")
+
+
+# ---------------------------------------------------------------------------
+# Shared low-level helpers (duplicated from worker.py — see module docstring)
+# ---------------------------------------------------------------------------
+
+def new_id() -> str:
+ """Generate a random UUID v4 using os.urandom."""
+ b = bytearray(os.urandom(16))
+ b[6] = (b[6] & 0x0F) | 0x40
+ b[8] = (b[8] & 0x3F) | 0x80
+ h = b.hex()
+ return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:]}"
+
+
+def verify_token(raw: str, secret: str):
+ """Return decoded payload dict or None if invalid/missing."""
+ if not raw:
+ return None
+ try:
+ token = raw.removeprefix("Bearer ").strip()
+ dot = token.rfind(".")
+ if dot == -1:
+ return None
+ p, sig = token[:dot], token[dot + 1:]
+ exp = _hmac.new(
+ secret.encode("utf-8"), p.encode("utf-8"), hashlib.sha256
+ ).hexdigest()
+ if not _hmac.compare_digest(sig, exp):
+ return None
+ padding = (4 - len(p) % 4) % 4
+ return json.loads(base64.b64decode(p + "=" * padding).decode("utf-8"))
+ except Exception:
+ return None
+
+
+# Public name so other modules (e.g. src/api/surveys.py) can build custom
+# Response objects with the correct CORS headers without reaching into a
+# leading-underscore "private" constant.
+CORS_HEADERS = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+}
+# Kept as an alias for backwards compatibility with any existing internal
+# references within this module.
+_CORS = CORS_HEADERS
+
+
+def json_resp(data, status: int = 200):
+ return Response(
+ json.dumps(data),
+ status=status,
+ headers={"Content-Type": "application/json", **CORS_HEADERS},
+ )
+
+
+def ok(data=None, msg: str = "OK"):
+ body = {"success": True, "message": msg}
+ if data is not None:
+ body["data"] = data
+ return json_resp(body, 200)
+
+
+def err(msg: str, status: int = 400, code: str | None = None):
+ body = {"error": msg}
+ if code:
+ body["code"] = code
+ return json_resp(body, status)
+
+
+async def parse_json_object(req):
+ """Parse request JSON and ensure payload is an object/dict."""
+ try:
+ text = await req.text()
+ body = json.loads(text)
+ except Exception:
+ return None, err("Invalid JSON body")
+
+ if not isinstance(body, dict):
+ return None, err("JSON body must be an object", 400)
+
+ return body, None
+
+
+def query_int(params: dict, key: str, default: int, min_val: int, max_val: int) -> int:
+ raw = (params.get(key) or [None])[0]
+ if raw is None:
+ return default
+ try:
+ return max(min_val, min(int(raw), max_val))
+ except (ValueError, TypeError):
+ return default
+
+
+def get_query_params(req) -> dict:
+ return parse_qs(urlparse(req.url).query)
+
+
+# ---------------------------------------------------------------------------
+# Validation
+# ---------------------------------------------------------------------------
+
+def validate_survey_payload(body: dict):
+ """Validate a create-survey request body.
+
+ Returns ``(cleaned, None)`` on success or ``(None, error_message)``.
+ ``cleaned`` is ``{"title", "description", "is_public", "questions"}``
+ where each question is
+ ``{"text", "type", "required", "options": [str, ...]}``.
+ """
+ title = (body.get("title") or "").strip()
+ if not title:
+ return None, "title is required"
+ if len(title) > MAX_TITLE_LEN:
+ return None, f"title must be {MAX_TITLE_LEN} characters or fewer"
+
+ description = (body.get("description") or "").strip()
+ is_public = bool(body.get("is_public", True))
+
+ questions = body.get("questions")
+ if not isinstance(questions, list) or len(questions) == 0:
+ return None, "at least 1 question is required"
+ if len(questions) > MAX_QUESTIONS:
+ return None, f"a survey may have at most {MAX_QUESTIONS} questions"
+
+ cleaned_questions = []
+ for i, q in enumerate(questions):
+ if not isinstance(q, dict):
+ return None, f"question {i + 1} is malformed"
+
+ text = (q.get("text") or "").strip()
+ if not text:
+ return None, f"question {i + 1} is missing text"
+
+ qtype = (q.get("type") or "").strip()
+ if qtype not in QUESTION_TYPES:
+ return None, f"question {i + 1} has an invalid type"
+
+ required = bool(q.get("required", False))
+
+ options = []
+ if qtype in OPTION_QUESTION_TYPES:
+ raw_options = q.get("options")
+ if not isinstance(raw_options, list):
+ raw_options = []
+ options = [str(o).strip() for o in raw_options if str(o).strip()]
+ if len(options) < 2:
+ return None, f"question {i + 1} needs at least 2 options"
+
+ cleaned_questions.append({
+ "text": text,
+ "type": qtype,
+ "required": required,
+ "options": options,
+ })
+
+ return {
+ "title": title,
+ "description": description,
+ "is_public": is_public,
+ "questions": cleaned_questions,
+ }, None
+
+
+# ---------------------------------------------------------------------------
+# Create
+# ---------------------------------------------------------------------------
+
+async def create_survey(env, user_id: str, cleaned: dict) -> str:
+ survey_id = new_id()
+ await env.DB.prepare(
+ "INSERT INTO surveys (id, user_id, title, description, is_public) "
+ "VALUES (?, ?, ?, ?, ?)"
+ ).bind(
+ survey_id, user_id, cleaned["title"], cleaned["description"],
+ 1 if cleaned["is_public"] else 0,
+ ).run()
+
+ for order, q in enumerate(cleaned["questions"]):
+ question_id = new_id()
+ await env.DB.prepare(
+ "INSERT INTO survey_questions "
+ "(id, survey_id, question_text, question_type, required, display_order) "
+ "VALUES (?, ?, ?, ?, ?, ?)"
+ ).bind(
+ question_id, survey_id, q["text"], q["type"],
+ 1 if q["required"] else 0, order,
+ ).run()
+
+ for opt_order, opt_text in enumerate(q["options"]):
+ await env.DB.prepare(
+ "INSERT INTO survey_options (id, question_id, option_text, display_order) "
+ "VALUES (?, ?, ?, ?)"
+ ).bind(new_id(), question_id, opt_text, opt_order).run()
+
+ return survey_id
+
+
+# ---------------------------------------------------------------------------
+# List / detail
+# ---------------------------------------------------------------------------
+
+async def list_surveys(env, search: str, limit: int, offset: int, viewer_id):
+ where = ["(s.is_public = 1"]
+ params = []
+ if viewer_id:
+ where[0] += " OR s.user_id = ?"
+ params.append(viewer_id)
+ where[0] += ")"
+
+ if search:
+ where.append("(s.title LIKE ? OR s.description LIKE ?)")
+ like = f"%{search}%"
+ params.extend([like, like])
+
+ where_sql = " AND ".join(where)
+ params.extend([limit, offset])
+
+ res = await env.DB.prepare(
+ "SELECT s.id, s.title, s.description, s.is_public, s.user_id, s.created_at, "
+ "u.username AS creator_username, "
+ "(SELECT COUNT(*) FROM survey_questions WHERE survey_id = s.id) AS question_count, "
+ "(SELECT COUNT(*) FROM survey_responses WHERE survey_id = s.id) AS participant_count "
+ "FROM surveys s JOIN users u ON s.user_id = u.id "
+ f"WHERE {where_sql} "
+ "ORDER BY s.created_at DESC LIMIT ? OFFSET ?"
+ ).bind(*params).all()
+
+ surveys = []
+ for r in res.results or []:
+ surveys.append({
+ "id": r.id,
+ "title": r.title,
+ "description": r.description,
+ "is_public": bool(r.is_public),
+ "question_count": r.question_count,
+ "participant_count": r.participant_count,
+ "creator_username": r.creator_username,
+ "created_at": r.created_at,
+ "is_owner": bool(viewer_id and r.user_id == viewer_id),
+ })
+ return surveys
+
+
+async def get_survey(env, survey_id: str, viewer_id):
+ survey = await env.DB.prepare(
+ "SELECT s.id, s.title, s.description, s.is_public, s.user_id, s.created_at, "
+ "s.updated_at, u.username AS creator_username "
+ "FROM surveys s JOIN users u ON s.user_id = u.id WHERE s.id = ?"
+ ).bind(survey_id).first()
+
+ if not survey:
+ return None
+
+ is_owner = bool(viewer_id and survey.user_id == viewer_id)
+ if not survey.is_public and not is_owner:
+ return None
+
+ q_res = await env.DB.prepare(
+ "SELECT id, question_text, question_type, required, display_order "
+ "FROM survey_questions WHERE survey_id = ? ORDER BY display_order"
+ ).bind(survey_id).all()
+
+ o_res = await env.DB.prepare(
+ "SELECT o.id, o.question_id, o.option_text, o.display_order "
+ "FROM survey_options o JOIN survey_questions q ON o.question_id = q.id "
+ "WHERE q.survey_id = ? ORDER BY o.display_order"
+ ).bind(survey_id).all()
+
+ options_by_question = {}
+ for o in o_res.results or []:
+ options_by_question.setdefault(o.question_id, []).append({
+ "id": o.id,
+ "text": o.option_text,
+ })
+
+ questions = []
+ for q in q_res.results or []:
+ questions.append({
+ "id": q.id,
+ "text": q.question_text,
+ "type": q.question_type,
+ "required": bool(q.required),
+ "options": options_by_question.get(q.id, []),
+ })
+
+ return {
+ "id": survey.id,
+ "title": survey.title,
+ "description": survey.description,
+ "is_public": bool(survey.is_public),
+ "creator_username": survey.creator_username,
+ "created_at": survey.created_at,
+ "updated_at": survey.updated_at,
+ "is_owner": is_owner,
+ "questions": questions,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Submit response
+# ---------------------------------------------------------------------------
+
+async def submit_response(env, survey_id: str, user_id, answers: dict):
+ """Validate and store a survey response.
+
+ ``answers`` maps ``question_id -> value`` where ``value`` is a string for
+ text/true_false/scale questions, or a list of strings for checkbox
+ questions.
+
+ Returns ``(response_id, None)`` on success or ``(None, error_message)``.
+ For a duplicate submission, ``error_message`` is the stable machine
+ code ``"already_submitted"`` rather than free-form text, so callers can
+ branch on it instead of pattern-matching a human-readable sentence.
+ """
+ survey = await env.DB.prepare(
+ "SELECT id, is_public, user_id FROM surveys WHERE id = ?"
+ ).bind(survey_id).first()
+ if not survey:
+ return None, "Survey not found"
+
+ q_res = await env.DB.prepare(
+ "SELECT id, question_type, required FROM survey_questions "
+ "WHERE survey_id = ? ORDER BY display_order"
+ ).bind(survey_id).all()
+ questions = q_res.results or []
+ if not questions:
+ return None, "Survey has no questions"
+
+ o_res = await env.DB.prepare(
+ "SELECT o.question_id, o.option_text FROM survey_options o "
+ "JOIN survey_questions q ON o.question_id = q.id WHERE q.survey_id = ?"
+ ).bind(survey_id).all()
+ valid_options = {}
+ for o in o_res.results or []:
+ valid_options.setdefault(o.question_id, set()).add(o.option_text)
+
+ if user_id:
+ existing = await env.DB.prepare(
+ "SELECT id FROM survey_responses WHERE survey_id = ? AND user_id = ?"
+ ).bind(survey_id, user_id).first()
+ if existing:
+ return None, "already_submitted"
+
+ cleaned_answers = [] # list of (question_id, answer_text)
+ for q in questions:
+ raw = answers.get(q.id)
+
+ if q.question_type == "checkbox":
+ values = raw if isinstance(raw, list) else ([] if raw is None else [raw])
+ values = [str(v).strip() for v in values if str(v).strip()]
+ if q.required and not values:
+ return None, "All required questions must be answered"
+ allowed = valid_options.get(q.id, set())
+ for v in values:
+ if v not in allowed:
+ return None, "Invalid option selected"
+ if values:
+ cleaned_answers.append((q.id, CHECKBOX_SEP.join(values)))
+ continue
+
+ value = "" if raw is None else str(raw).strip()
+ if q.required and not value:
+ return None, "All required questions must be answered"
+ if not value:
+ continue
+
+ if q.question_type == "multiple_choice":
+ if value not in valid_options.get(q.id, set()):
+ return None, "Invalid option selected"
+ elif q.question_type == "true_false":
+ if value not in ("true", "false"):
+ return None, "Invalid true/false value"
+ elif q.question_type == "scale":
+ try:
+ n = int(value)
+ except ValueError:
+ return None, "Invalid scale value"
+ if n < 1 or n > 5:
+ return None, "Scale value must be between 1 and 5"
+ value = str(n)
+
+ cleaned_answers.append((q.id, value))
+
+ response_id = new_id()
+ await env.DB.prepare(
+ "INSERT INTO survey_responses (id, survey_id, user_id) VALUES (?, ?, ?)"
+ ).bind(response_id, survey_id, user_id).run()
+
+ for question_id, answer_text in cleaned_answers:
+ await env.DB.prepare(
+ "INSERT INTO survey_answers (id, response_id, question_id, answer_text) "
+ "VALUES (?, ?, ?, ?)"
+ ).bind(new_id(), response_id, question_id, answer_text).run()
+
+ return response_id, None
+
+
+# ---------------------------------------------------------------------------
+# Results / analytics
+# ---------------------------------------------------------------------------
+
+async def get_results(env, survey_id: str, viewer_id):
+ survey = await env.DB.prepare(
+ "SELECT id, title, is_public, user_id FROM surveys WHERE id = ?"
+ ).bind(survey_id).first()
+ if not survey:
+ return None, "Survey not found"
+ if not survey.is_public and not (viewer_id and survey.user_id == viewer_id):
+ return None, "Survey not found"
+
+ q_res = await env.DB.prepare(
+ "SELECT id, question_text, question_type, required, display_order "
+ "FROM survey_questions WHERE survey_id = ? ORDER BY display_order"
+ ).bind(survey_id).all()
+ questions = q_res.results or []
+
+ o_res = await env.DB.prepare(
+ "SELECT o.question_id, o.option_text, o.display_order FROM survey_options o "
+ "JOIN survey_questions q ON o.question_id = q.id "
+ "WHERE q.survey_id = ? ORDER BY o.display_order"
+ ).bind(survey_id).all()
+ options_by_question = {}
+ for o in o_res.results or []:
+ options_by_question.setdefault(o.question_id, []).append(o.option_text)
+
+ r_res = await env.DB.prepare(
+ "SELECT id FROM survey_responses WHERE survey_id = ?"
+ ).bind(survey_id).all()
+ response_ids = [r.id for r in (r_res.results or [])]
+ participant_count = len(response_ids)
+
+ a_res = await env.DB.prepare(
+ "SELECT a.response_id, a.question_id, a.answer_text FROM survey_answers a "
+ "JOIN survey_responses r ON a.response_id = r.id WHERE r.survey_id = ?"
+ ).bind(survey_id).all()
+ answers = a_res.results or []
+
+ answers_by_question = {}
+ answered_by_response = {}
+ for a in answers:
+ answers_by_question.setdefault(a.question_id, []).append(a)
+ answered_by_response.setdefault(a.response_id, set()).add(a.question_id)
+
+ required_ids = {q.id for q in questions if q.required}
+ fully_completed = 0
+ total_answer_ratio = 0.0
+ for rid in response_ids:
+ answered = answered_by_response.get(rid, set())
+ if required_ids.issubset(answered):
+ fully_completed += 1
+ if questions:
+ total_answer_ratio += len(answered) / len(questions)
+
+ completion_rate = round((fully_completed / participant_count) * 100, 1) if participant_count else 0.0
+ avg_answer_ratio = (total_answer_ratio / participant_count) if participant_count else 0.0
+ engagement_score = round(completion_rate * 0.7 + avg_answer_ratio * 100 * 0.3, 1)
+
+ question_results = []
+ for q in questions:
+ q_answers = answers_by_question.get(q.id, [])
+ entry = {
+ "id": q.id,
+ "text": q.question_text,
+ "type": q.question_type,
+ "required": bool(q.required),
+ "response_count": len(q_answers),
+ }
+
+ if q.question_type in ("multiple_choice", "checkbox"):
+ option_list = options_by_question.get(q.id, [])
+ counts = {opt: 0 for opt in option_list}
+ total_selections = 0
+ for a in q_answers:
+ values = a.answer_text.split(CHECKBOX_SEP) if q.question_type == "checkbox" else [a.answer_text]
+ for v in values:
+ if v in counts:
+ counts[v] += 1
+ total_selections += 1
+ percentages = {
+ opt: (round(counts[opt] / total_selections * 100, 1) if total_selections else 0.0)
+ for opt in option_list
+ }
+ entry["options"] = [
+ {"text": opt, "count": counts[opt], "percentage": percentages[opt]}
+ for opt in option_list
+ ]
+
+ elif q.question_type == "true_false":
+ yes = sum(1 for a in q_answers if a.answer_text == "true")
+ no = sum(1 for a in q_answers if a.answer_text == "false")
+ entry["yes_count"] = yes
+ entry["no_count"] = no
+
+ elif q.question_type == "scale":
+ values = [int(a.answer_text) for a in q_answers if a.answer_text.isdigit()]
+ distribution = {str(n): values.count(n) for n in range(1, 6)}
+ entry["average"] = round(sum(values) / len(values), 2) if values else 0.0
+ entry["min"] = min(values) if values else 0
+ entry["max"] = max(values) if values else 0
+ entry["distribution"] = distribution
+
+ elif q.question_type == "text":
+ entry["responses"] = [a.answer_text for a in q_answers]
+
+ question_results.append(entry)
+
+ return {
+ "survey_id": survey.id,
+ "title": survey.title,
+ "participant_count": participant_count,
+ "completion_rate": completion_rate,
+ "engagement_score": engagement_score,
+ "question_count": len(questions),
+ "questions": question_results,
+ }, None
+
+
+# ---------------------------------------------------------------------------
+# Delete (explicit cascade — D1 does not enforce FK pragmas by default)
+# ---------------------------------------------------------------------------
+
+async def delete_survey(env, survey_id: str, user_id: str):
+ survey = await env.DB.prepare(
+ "SELECT user_id FROM surveys WHERE id = ?"
+ ).bind(survey_id).first()
+ if not survey:
+ return False, "Survey not found"
+ if survey.user_id != user_id:
+ return False, "Only the survey creator can delete this survey"
+
+ # Run the full cascade as a single atomic batch so a mid-cascade failure
+ # can't leave orphaned rows behind.
+ statements = [
+ env.DB.prepare(
+ "DELETE FROM survey_answers WHERE question_id IN "
+ "(SELECT id FROM survey_questions WHERE survey_id = ?)"
+ ).bind(survey_id),
+ env.DB.prepare(
+ "DELETE FROM survey_responses WHERE survey_id = ?"
+ ).bind(survey_id),
+ env.DB.prepare(
+ "DELETE FROM survey_options WHERE question_id IN "
+ "(SELECT id FROM survey_questions WHERE survey_id = ?)"
+ ).bind(survey_id),
+ env.DB.prepare(
+ "DELETE FROM survey_questions WHERE survey_id = ?"
+ ).bind(survey_id),
+ env.DB.prepare(
+ "DELETE FROM surveys WHERE id = ?"
+ ).bind(survey_id),
+ ]
+ await env.DB.batch(statements)
+
+ return True, None
+
+
+# ---------------------------------------------------------------------------
+# Export CSV
+# ---------------------------------------------------------------------------
+
+def _csv_safe(value) -> str:
+ """Neutralize CSV/formula injection (CWE-1236).
+
+ Prefixes values that start with a formula-trigger character with a
+ single quote so spreadsheet apps (Excel/Sheets) render them as literal
+ text instead of executing them as a formula.
+ """
+ s = "" if value is None else str(value)
+ if s.startswith(_CSV_FORMULA_PREFIXES):
+ return "'" + s
+ return s
+
+
+async def export_csv(env, survey_id: str, viewer_id):
+ survey = await env.DB.prepare(
+ "SELECT id, title, is_public, user_id FROM surveys WHERE id = ?"
+ ).bind(survey_id).first()
+ if not survey:
+ return None, "Survey not found"
+ if not survey.is_public and not (viewer_id and survey.user_id == viewer_id):
+ return None, "Survey not found"
+
+ q_res = await env.DB.prepare(
+ "SELECT id, question_text FROM survey_questions "
+ "WHERE survey_id = ? ORDER BY display_order"
+ ).bind(survey_id).all()
+ questions = q_res.results or []
+
+ r_res = await env.DB.prepare(
+ "SELECT r.id, r.user_id, r.submitted_at, u.username FROM survey_responses r "
+ "LEFT JOIN users u ON r.user_id = u.id WHERE r.survey_id = ? ORDER BY r.submitted_at"
+ ).bind(survey_id).all()
+ responses = r_res.results or []
+
+ a_res = await env.DB.prepare(
+ "SELECT a.response_id, a.question_id, a.answer_text FROM survey_answers a "
+ "JOIN survey_responses r ON a.response_id = r.id WHERE r.survey_id = ?"
+ ).bind(survey_id).all()
+
+ answers_by_response = {}
+ for a in (a_res.results or []):
+ answers_by_response.setdefault(a.response_id, {})[a.question_id] = a.answer_text.replace(CHECKBOX_SEP, "; ")
+
+ buf = io.StringIO()
+ writer = csv.writer(buf)
+ writer.writerow(["Response ID", "Participant", "Submitted At"] + [q.question_text for q in questions])
+ for r in responses:
+ row_answers = answers_by_response.get(r.id, {})
+ writer.writerow(
+ [r.id, _csv_safe(r.username or "Anonymous"), r.submitted_at]
+ + [_csv_safe(row_answers.get(q.id, "")) for q in questions]
+ )
+
+ return buf.getvalue(), None
\ No newline at end of file
diff --git a/src/worker.py b/src/worker.py
index f274a07..68979a9 100644
--- a/src/worker.py
+++ b/src/worker.py
@@ -14,6 +14,13 @@
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]
+ GET /api/surveys – list surveys (?q=&limit=&offset=)
+ POST /api/surveys – create survey [auth]
+ GET /api/surveys/:id – survey detail
+ DELETE /api/surveys/:id – delete survey [owner]
+ POST /api/surveys/:id/responses – submit a response
+ GET /api/surveys/:id/results – survey analytics
+ GET /api/surveys/:id/export – export responses as CSV
Security model
* ALL user PII (username, email, display name, role) is encrypted with
@@ -39,12 +46,29 @@
import json
import os
import re
+import sys
import traceback
from types import SimpleNamespace
from typing import Any, Dict, Optional
from urllib.parse import urlparse, parse_qs, urlencode
+# Allow importing sibling modules (surveys.py, api/surveys.py) — the
+# Worker entrypoint is loaded by absolute path, so its own directory is not
+# automatically on sys.path.
+_SRC_DIR = os.path.dirname(os.path.abspath(__file__))
+if _SRC_DIR not in sys.path:
+ sys.path.insert(0, _SRC_DIR)
+
from workers import Response, DurableObject
+from api.surveys import (
+ api_list_surveys,
+ api_create_survey,
+ api_get_survey,
+ api_submit_survey_response,
+ api_get_survey_results,
+ api_delete_survey,
+ api_export_survey,
+)
import js
from pyodide.ffi import to_js
@@ -2708,6 +2732,30 @@ async def _dispatch(request, env):
if path == "/api/notification-preferences" and method == "PATCH":
return await api_patch_notification_preferences(request, env)
+ # Surveys
+ if path == "/api/surveys" and method == "GET":
+ return await api_list_surveys(request, env)
+ if path == "/api/surveys" and method == "POST":
+ return await api_create_survey(request, env)
+
+ m_survey_results = re.fullmatch(r"/api/surveys/([A-Za-z0-9_-]+)/results", path)
+ if m_survey_results and method == "GET":
+ return await api_get_survey_results(m_survey_results.group(1), request, env)
+
+ m_survey_export = re.fullmatch(r"/api/surveys/([A-Za-z0-9_-]+)/export", path)
+ if m_survey_export and method == "GET":
+ return await api_export_survey(m_survey_export.group(1), request, env)
+
+ m_survey_responses = re.fullmatch(r"/api/surveys/([A-Za-z0-9_-]+)/responses", path)
+ if m_survey_responses and method == "POST":
+ return await api_submit_survey_response(m_survey_responses.group(1), request, env)
+
+ m_survey = re.fullmatch(r"/api/surveys/([A-Za-z0-9_-]+)", path)
+ if m_survey and method == "GET":
+ return await api_get_survey(m_survey.group(1), request, env)
+ if m_survey and method == "DELETE":
+ return await api_delete_survey(m_survey.group(1), request, env)
+
return err("API endpoint not found", 404)
return await serve_static(path, env)
diff --git a/tests/test_api_surveys.py b/tests/test_api_surveys.py
new file mode 100644
index 0000000..a25e256
--- /dev/null
+++ b/tests/test_api_surveys.py
@@ -0,0 +1,631 @@
+"""
+Tests for the survey system API handlers (src/api/surveys.py, src/surveys.py):
+ * api_list_surveys()
+ * api_create_survey()
+ * api_get_survey()
+ * api_submit_survey_response()
+ * api_get_survey_results()
+ * api_delete_survey()
+ * api_export_survey()
+"""
+
+import json
+
+from tests.helpers import load_worker, MockRequest, MockRow, MockDB, make_env, make_stmt, json_request
+
+worker = load_worker()
+
+JWT = "test-jwt-secret"
+
+
+def _parse(resp):
+ return json.loads(resp.body)
+
+
+def _token(uid="user-1", username="alice", role="user"):
+ return worker.create_token(uid, username, role, JWT)
+
+
+def _auth_header(token):
+ return {"Authorization": f"Bearer {token}"}
+
+
+def _get_req(path, token=None):
+ headers = _auth_header(token) if token else {}
+ return MockRequest(method="GET", url=f"http://localhost{path}", headers=headers)
+
+
+def _delete_req(path, token=None):
+ headers = _auth_header(token) if token else {}
+ return MockRequest(method="DELETE", url=f"http://localhost{path}", headers=headers)
+
+
+def _basic_survey_payload(qtype="text"):
+ return {
+ "title": "Customer Feedback",
+ "description": "Tell us what you think",
+ "is_public": True,
+ "questions": [
+ {"text": "How was your experience?", "type": qtype, "required": True,
+ **({"options": ["Good", "Bad"]} if qtype in ("multiple_choice", "checkbox") else {})},
+ ],
+ }
+
+
+# ---------------------------------------------------------------------------
+# Local helper: MockDB variant that supports D1's batch() API.
+#
+# ``delete_survey()`` in src/surveys.py issues its cascading deletes via
+# ``env.DB.batch([...])`` (a real method on Cloudflare's D1 binding) so the
+# whole cascade commits atomically. The shared ``MockDB`` in tests/helpers.py
+# intentionally only stubs the primitives the rest of the suite relies on
+# (``prepare``/``bind``/``run``/``first``/``all``), so rather than widen that
+# shared double for every test in the project, we extend it locally here,
+# scoped to just the survey-deletion test that needs it.
+# ---------------------------------------------------------------------------
+
+class BatchMockDB(MockDB):
+ async def batch(self, statements):
+ results = []
+ for stmt in statements:
+ results.append(await stmt.run())
+ return results
+
+
+# ---------------------------------------------------------------------------
+# api_create_survey()
+# ---------------------------------------------------------------------------
+
+class TestApiCreateSurvey:
+ def _req(self, payload, token=None):
+ headers = _auth_header(token) if token else {}
+ return json_request("/api/surveys", payload, headers=headers)
+
+ async def test_no_auth_returns_401(self):
+ env = make_env()
+ r = await worker.api_create_survey(self._req(_basic_survey_payload()), env)
+ assert r.status == 401
+
+ async def test_missing_title_returns_400(self):
+ token = _token()
+ body = _basic_survey_payload()
+ body["title"] = " "
+ env = make_env()
+ r = await worker.api_create_survey(self._req(body, token), env)
+ assert r.status == 400
+ assert "title" in _parse(r)["error"]
+
+ async def test_title_too_long_returns_400(self):
+ token = _token()
+ body = _basic_survey_payload()
+ body["title"] = "x" * 201
+ env = make_env()
+ r = await worker.api_create_survey(self._req(body, token), env)
+ assert r.status == 400
+
+ async def test_no_questions_returns_400(self):
+ token = _token()
+ body = _basic_survey_payload()
+ body["questions"] = []
+ env = make_env()
+ r = await worker.api_create_survey(self._req(body, token), env)
+ assert r.status == 400
+ assert "question" in _parse(r)["error"]
+
+ async def test_too_many_questions_returns_400(self):
+ token = _token()
+ body = _basic_survey_payload()
+ body["questions"] = [{"text": f"Q{i}", "type": "text"} for i in range(51)]
+ env = make_env()
+ r = await worker.api_create_survey(self._req(body, token), env)
+ assert r.status == 400
+
+ async def test_invalid_question_type_returns_400(self):
+ token = _token()
+ body = _basic_survey_payload()
+ body["questions"][0]["type"] = "essay"
+ env = make_env()
+ r = await worker.api_create_survey(self._req(body, token), env)
+ assert r.status == 400
+
+ async def test_missing_question_text_returns_400(self):
+ token = _token()
+ body = _basic_survey_payload()
+ body["questions"][0]["text"] = ""
+ env = make_env()
+ r = await worker.api_create_survey(self._req(body, token), env)
+ assert r.status == 400
+
+ async def test_multiple_choice_without_enough_options_returns_400(self):
+ token = _token()
+ body = _basic_survey_payload(qtype="multiple_choice")
+ body["questions"][0]["options"] = ["Only One"]
+ env = make_env()
+ r = await worker.api_create_survey(self._req(body, token), env)
+ assert r.status == 400
+ assert "options" in _parse(r)["error"]
+
+ async def test_checkbox_without_options_returns_400(self):
+ token = _token()
+ body = _basic_survey_payload(qtype="checkbox")
+ body["questions"][0]["options"] = []
+ env = make_env()
+ r = await worker.api_create_survey(self._req(body, token), env)
+ assert r.status == 400
+
+ async def test_invalid_json_body_returns_400(self):
+ token = _token()
+ req = MockRequest(method="POST", url="http://localhost/api/surveys",
+ headers={**_auth_header(token), "Content-Type": "application/json"},
+ body="not json")
+ env = make_env()
+ r = await worker.api_create_survey(req, env)
+ assert r.status == 400
+
+ async def test_successful_creation_text_question(self):
+ token = _token()
+ env = make_env(db=MockDB())
+ r = await worker.api_create_survey(self._req(_basic_survey_payload(), token), env)
+ assert r.status == 200
+ data = _parse(r)
+ assert data["success"] is True
+ assert data["data"]["title"] == "Customer Feedback"
+
+ async def test_successful_creation_with_options(self):
+ token = _token()
+ env = make_env(db=MockDB())
+ r = await worker.api_create_survey(
+ self._req(_basic_survey_payload(qtype="multiple_choice"), token), env
+ )
+ assert r.status == 200
+
+ async def test_is_public_defaults_true_when_omitted(self):
+ token = _token()
+ body = _basic_survey_payload()
+ del body["is_public"]
+ env = make_env(db=MockDB())
+ r = await worker.api_create_survey(self._req(body, token), env)
+ assert r.status == 200
+
+
+# ---------------------------------------------------------------------------
+# api_list_surveys()
+# ---------------------------------------------------------------------------
+
+class TestApiListSurveys:
+ def _row(self, sid="s1", title="Feedback", user_id="user-1", is_public=1):
+ return MockRow(
+ id=sid, title=title, description="desc", is_public=is_public, user_id=user_id,
+ created_at="2024-01-01T00:00:00", creator_username="alice",
+ question_count=3, participant_count=10,
+ )
+
+ async def test_returns_surveys_list(self):
+ env = make_env(db=MockDB([make_stmt(all_results=[self._row()])]))
+ r = await worker.api_list_surveys(_get_req("/api/surveys"), env)
+ assert r.status == 200
+ data = _parse(r)
+ assert len(data["surveys"]) == 1
+ assert data["surveys"][0]["title"] == "Feedback"
+
+ async def test_empty_list(self):
+ env = make_env(db=MockDB([make_stmt(all_results=[])]))
+ r = await worker.api_list_surveys(_get_req("/api/surveys"), env)
+ assert _parse(r)["surveys"] == []
+
+ async def test_is_owner_true_for_creator(self):
+ token = _token(uid="user-1")
+ env = make_env(db=MockDB([make_stmt(all_results=[self._row(user_id="user-1")])]))
+ r = await worker.api_list_surveys(_get_req("/api/surveys", token), env)
+ assert _parse(r)["surveys"][0]["is_owner"] is True
+
+ async def test_is_owner_false_for_anonymous(self):
+ env = make_env(db=MockDB([make_stmt(all_results=[self._row(user_id="user-1")])]))
+ r = await worker.api_list_surveys(_get_req("/api/surveys"), env)
+ assert _parse(r)["surveys"][0]["is_owner"] is False
+
+ async def test_search_query_param_accepted(self):
+ env = make_env(db=MockDB([make_stmt(all_results=[])]))
+ r = await worker.api_list_surveys(_get_req("/api/surveys?q=feedback"), env)
+ assert r.status == 200
+
+ async def test_pagination_params_clamped(self):
+ env = make_env(db=MockDB([make_stmt(all_results=[])]))
+ r = await worker.api_list_surveys(_get_req("/api/surveys?limit=500&offset=-5"), env)
+ data = _parse(r)
+ assert data["limit"] == 50
+
+
+# ---------------------------------------------------------------------------
+# api_get_survey()
+# ---------------------------------------------------------------------------
+
+class TestApiGetSurvey:
+ def _survey_row(self, sid="s1", user_id="user-1", is_public=1):
+ return MockRow(
+ id=sid, title="Feedback", description="desc", is_public=is_public,
+ user_id=user_id, created_at="2024-01-01", updated_at="2024-01-01",
+ creator_username="alice",
+ )
+
+ def _question_row(self, qid="q1"):
+ return MockRow(id=qid, question_text="How was it?", question_type="multiple_choice",
+ required=1, display_order=0)
+
+ def _option_row(self, qid="q1"):
+ return MockRow(id="o1", question_id=qid, option_text="Good", display_order=0)
+
+ async def test_not_found_returns_404(self):
+ env = make_env(db=MockDB([make_stmt(first=None)]))
+ r = await worker.api_get_survey("missing", _get_req("/api/surveys/missing"), env)
+ assert r.status == 404
+
+ async def test_public_survey_visible_to_anonymous(self):
+ env = make_env(db=MockDB([
+ make_stmt(first=self._survey_row()),
+ make_stmt(all_results=[self._question_row()]),
+ make_stmt(all_results=[self._option_row()]),
+ ]))
+ r = await worker.api_get_survey("s1", _get_req("/api/surveys/s1"), env)
+ assert r.status == 200
+ data = _parse(r)["survey"]
+ assert data["title"] == "Feedback"
+ assert len(data["questions"]) == 1
+ assert len(data["questions"][0]["options"]) == 1
+
+ async def test_private_survey_hidden_from_non_owner(self):
+ token = _token(uid="someone-else")
+ env = make_env(db=MockDB([make_stmt(first=self._survey_row(is_public=0))]))
+ r = await worker.api_get_survey("s1", _get_req("/api/surveys/s1", token), env)
+ assert r.status == 404
+
+ async def test_private_survey_visible_to_owner(self):
+ token = _token(uid="user-1")
+ env = make_env(db=MockDB([
+ make_stmt(first=self._survey_row(is_public=0)),
+ make_stmt(all_results=[self._question_row()]),
+ make_stmt(all_results=[]),
+ ]))
+ r = await worker.api_get_survey("s1", _get_req("/api/surveys/s1", token), env)
+ assert r.status == 200
+ assert _parse(r)["survey"]["is_owner"] is True
+
+
+# ---------------------------------------------------------------------------
+# api_submit_survey_response()
+# ---------------------------------------------------------------------------
+
+class TestApiSubmitSurveyResponse:
+ def _req(self, survey_id, answers, token=None):
+ headers = _auth_header(token) if token else {}
+ return json_request(f"/api/surveys/{survey_id}/responses", {"answers": answers}, headers=headers)
+
+ def _survey_row(self, sid="s1", is_public=1, user_id="user-1"):
+ return MockRow(id=sid, is_public=is_public, user_id=user_id)
+
+ def _text_question(self):
+ return MockRow(id="q1", question_type="text", required=True)
+
+ def _mc_question(self):
+ return MockRow(id="q1", question_type="multiple_choice", required=True)
+
+ def _option_row(self, text="Good"):
+ return MockRow(question_id="q1", option_text=text)
+
+ async def test_survey_not_found_returns_404(self):
+ env = make_env(db=MockDB([make_stmt(first=None)]))
+ r = await worker.api_submit_survey_response("missing", self._req("missing", {"q1": "hi"}), env)
+ assert r.status == 404
+
+ async def test_answers_must_be_object(self):
+ env = make_env()
+ req = json_request("/api/surveys/s1/responses", {"answers": "not-an-object"})
+ r = await worker.api_submit_survey_response("s1", req, env)
+ assert r.status == 400
+
+ async def test_no_questions_returns_400(self):
+ env = make_env(db=MockDB([
+ make_stmt(first=self._survey_row()),
+ make_stmt(all_results=[]),
+ ]))
+ r = await worker.api_submit_survey_response("s1", self._req("s1", {}), env)
+ assert r.status == 400
+
+ async def test_required_question_missing_returns_400(self):
+ env = make_env(db=MockDB([
+ make_stmt(first=self._survey_row()),
+ make_stmt(all_results=[self._text_question()]),
+ make_stmt(all_results=[]),
+ ]))
+ r = await worker.api_submit_survey_response("s1", self._req("s1", {}), env)
+ assert r.status == 400
+ assert "required" in _parse(r)["error"].lower()
+
+ async def test_invalid_option_returns_400(self):
+ env = make_env(db=MockDB([
+ make_stmt(first=self._survey_row()),
+ make_stmt(all_results=[self._mc_question()]),
+ make_stmt(all_results=[self._option_row("Good")]),
+ ]))
+ r = await worker.api_submit_survey_response("s1", self._req("s1", {"q1": "Not An Option"}), env)
+ assert r.status == 400
+
+ async def test_successful_anonymous_submission(self):
+ env = make_env(db=MockDB([
+ make_stmt(first=self._survey_row()),
+ make_stmt(all_results=[self._text_question()]),
+ make_stmt(all_results=[]),
+ ]))
+ r = await worker.api_submit_survey_response("s1", self._req("s1", {"q1": "Great!"}), env)
+ assert r.status == 200
+ assert _parse(r)["data"]["response_id"]
+
+ async def test_successful_authenticated_submission(self):
+ token = _token(uid="user-9")
+ env = make_env(db=MockDB([
+ make_stmt(first=self._survey_row()),
+ make_stmt(all_results=[self._text_question()]),
+ make_stmt(all_results=[]),
+ make_stmt(first=None), # no existing response
+ ]))
+ r = await worker.api_submit_survey_response("s1", self._req("s1", {"q1": "Great!"}, token), env)
+ assert r.status == 200
+
+ async def test_duplicate_submission_blocked(self):
+ token = _token(uid="user-9")
+ env = make_env(db=MockDB([
+ make_stmt(first=self._survey_row()),
+ make_stmt(all_results=[self._text_question()]),
+ make_stmt(all_results=[]),
+ make_stmt(first=MockRow(id="existing-response")),
+ ]))
+ r = await worker.api_submit_survey_response("s1", self._req("s1", {"q1": "Again"}, token), env)
+ assert r.status == 400
+ assert "already" in _parse(r)["error"].lower()
+
+ async def test_checkbox_multiple_selections_accepted(self):
+ q = MockRow(id="q1", question_type="checkbox", required=False)
+ env = make_env(db=MockDB([
+ make_stmt(first=self._survey_row()),
+ make_stmt(all_results=[q]),
+ make_stmt(all_results=[self._option_row("Good"), self._option_row("Bad")]),
+ ]))
+ r = await worker.api_submit_survey_response("s1", self._req("s1", {"q1": ["Good", "Bad"]}), env)
+ assert r.status == 200
+
+ async def test_scale_out_of_range_returns_400(self):
+ q = MockRow(id="q1", question_type="scale", required=True)
+ env = make_env(db=MockDB([
+ make_stmt(first=self._survey_row()),
+ make_stmt(all_results=[q]),
+ make_stmt(all_results=[]),
+ ]))
+ r = await worker.api_submit_survey_response("s1", self._req("s1", {"q1": "9"}), env)
+ assert r.status == 400
+
+ async def test_true_false_invalid_value_returns_400(self):
+ q = MockRow(id="q1", question_type="true_false", required=True)
+ env = make_env(db=MockDB([
+ make_stmt(first=self._survey_row()),
+ make_stmt(all_results=[q]),
+ make_stmt(all_results=[]),
+ ]))
+ r = await worker.api_submit_survey_response("s1", self._req("s1", {"q1": "maybe"}), env)
+ assert r.status == 400
+
+
+# ---------------------------------------------------------------------------
+# api_get_survey_results()
+# ---------------------------------------------------------------------------
+
+class TestApiGetSurveyResults:
+ def _survey_row(self, sid="s1", is_public=1, user_id="user-1"):
+ return MockRow(id=sid, title="Feedback", is_public=is_public, user_id=user_id)
+
+ async def test_not_found_returns_404(self):
+ env = make_env(db=MockDB([make_stmt(first=None)]))
+ r = await worker.api_get_survey_results("missing", _get_req("/api/surveys/missing/results"), env)
+ assert r.status == 404
+
+ async def test_private_survey_hidden_from_non_owner(self):
+ token = _token(uid="someone-else")
+ env = make_env(db=MockDB([make_stmt(first=self._survey_row(is_public=0))]))
+ r = await worker.api_get_survey_results("s1", _get_req("/api/surveys/s1/results", token), env)
+ assert r.status == 404
+
+ async def test_multiple_choice_counts_and_percentages(self):
+ q = MockRow(id="q1", question_text="Pick", question_type="multiple_choice",
+ required=1, display_order=0)
+ opts = [MockRow(question_id="q1", option_text="A", display_order=0),
+ MockRow(question_id="q1", option_text="B", display_order=1)]
+ responses = [MockRow(id="r1"), MockRow(id="r2")]
+ answers = [MockRow(response_id="r1", question_id="q1", answer_text="A"),
+ MockRow(response_id="r2", question_id="q1", answer_text="B")]
+ env = make_env(db=MockDB([
+ make_stmt(first=self._survey_row()),
+ make_stmt(all_results=[q]),
+ make_stmt(all_results=opts),
+ make_stmt(all_results=responses),
+ make_stmt(all_results=answers),
+ ]))
+ r = await worker.api_get_survey_results("s1", _get_req("/api/surveys/s1/results"), env)
+ assert r.status == 200
+ results = _parse(r)["results"]
+ assert results["participant_count"] == 2
+ assert results["completion_rate"] == 100.0
+ opt_a = next(o for o in results["questions"][0]["options"] if o["text"] == "A")
+ assert opt_a["count"] == 1
+ assert opt_a["percentage"] == 50.0
+
+ async def test_true_false_yes_no_totals(self):
+ q = MockRow(id="q1", question_text="Like it?", question_type="true_false",
+ required=0, display_order=0)
+ responses = [MockRow(id="r1"), MockRow(id="r2"), MockRow(id="r3")]
+ answers = [
+ MockRow(response_id="r1", question_id="q1", answer_text="true"),
+ MockRow(response_id="r2", question_id="q1", answer_text="true"),
+ MockRow(response_id="r3", question_id="q1", answer_text="false"),
+ ]
+ env = make_env(db=MockDB([
+ make_stmt(first=self._survey_row()),
+ make_stmt(all_results=[q]),
+ make_stmt(all_results=[]),
+ make_stmt(all_results=responses),
+ make_stmt(all_results=answers),
+ ]))
+ r = await worker.api_get_survey_results("s1", _get_req("/api/surveys/s1/results"), env)
+ q_result = _parse(r)["results"]["questions"][0]
+ assert q_result["yes_count"] == 2
+ assert q_result["no_count"] == 1
+
+ async def test_scale_avg_min_max_distribution(self):
+ q = MockRow(id="q1", question_text="Rate us", question_type="scale",
+ required=0, display_order=0)
+ responses = [MockRow(id="r1"), MockRow(id="r2"), MockRow(id="r3")]
+ answers = [
+ MockRow(response_id="r1", question_id="q1", answer_text="3"),
+ MockRow(response_id="r2", question_id="q1", answer_text="5"),
+ MockRow(response_id="r3", question_id="q1", answer_text="1"),
+ ]
+ env = make_env(db=MockDB([
+ make_stmt(first=self._survey_row()),
+ make_stmt(all_results=[q]),
+ make_stmt(all_results=[]),
+ make_stmt(all_results=responses),
+ make_stmt(all_results=answers),
+ ]))
+ r = await worker.api_get_survey_results("s1", _get_req("/api/surveys/s1/results"), env)
+ q_result = _parse(r)["results"]["questions"][0]
+ assert q_result["average"] == 3.0
+ assert q_result["min"] == 1
+ assert q_result["max"] == 5
+ assert q_result["distribution"]["1"] == 1
+ assert q_result["distribution"]["5"] == 1
+
+ async def test_text_responses_listed(self):
+ q = MockRow(id="q1", question_text="Comments?", question_type="text",
+ required=0, display_order=0)
+ responses = [MockRow(id="r1"), MockRow(id="r2")]
+ answers = [
+ MockRow(response_id="r1", question_id="q1", answer_text="Great course!"),
+ MockRow(response_id="r2", question_id="q1", answer_text="Could be better"),
+ ]
+ env = make_env(db=MockDB([
+ make_stmt(first=self._survey_row()),
+ make_stmt(all_results=[q]),
+ make_stmt(all_results=[]),
+ make_stmt(all_results=responses),
+ make_stmt(all_results=answers),
+ ]))
+ r = await worker.api_get_survey_results("s1", _get_req("/api/surveys/s1/results"), env)
+ q_result = _parse(r)["results"]["questions"][0]
+ assert "Great course!" in q_result["responses"]
+ assert len(q_result["responses"]) == 2
+
+ async def test_no_participants_yields_zero_stats(self):
+ q = MockRow(id="q1", question_text="Comments?", question_type="text",
+ required=0, display_order=0)
+ env = make_env(db=MockDB([
+ make_stmt(first=self._survey_row()),
+ make_stmt(all_results=[q]),
+ make_stmt(all_results=[]),
+ make_stmt(all_results=[]),
+ make_stmt(all_results=[]),
+ ]))
+ r = await worker.api_get_survey_results("s1", _get_req("/api/surveys/s1/results"), env)
+ results = _parse(r)["results"]
+ assert results["participant_count"] == 0
+ assert results["completion_rate"] == 0.0
+
+
+# ---------------------------------------------------------------------------
+# api_delete_survey()
+# ---------------------------------------------------------------------------
+
+class TestApiDeleteSurvey:
+ async def test_no_auth_returns_401(self):
+ env = make_env()
+ r = await worker.api_delete_survey("s1", _delete_req("/api/surveys/s1"), env)
+ assert r.status == 401
+
+ async def test_not_found_returns_404(self):
+ token = _token(uid="user-1")
+ env = make_env(db=MockDB([make_stmt(first=None)]))
+ r = await worker.api_delete_survey("s1", _delete_req("/api/surveys/s1", token), env)
+ assert r.status == 404
+
+ async def test_non_owner_returns_403(self):
+ token = _token(uid="someone-else")
+ env = make_env(db=MockDB([make_stmt(first=MockRow(user_id="user-1"))]))
+ r = await worker.api_delete_survey("s1", _delete_req("/api/surveys/s1", token), env)
+ assert r.status == 403
+
+ async def test_owner_can_delete(self):
+ token = _token(uid="user-1")
+ # delete_survey() runs its cascade via env.DB.batch([...]); use the
+ # local batch-aware MockDB subclass so that call has something to hit.
+ env = make_env(db=BatchMockDB([make_stmt(first=MockRow(user_id="user-1"))]))
+ r = await worker.api_delete_survey("s1", _delete_req("/api/surveys/s1", token), env)
+ assert r.status == 200
+ assert _parse(r)["success"] is True
+
+
+# ---------------------------------------------------------------------------
+# api_export_survey()
+# ---------------------------------------------------------------------------
+
+class TestApiExportSurvey:
+ def _survey_row(self, is_public=1, user_id="user-1"):
+ return MockRow(id="s1", title="Feedback", is_public=is_public, user_id=user_id)
+
+ async def test_not_found_returns_404(self):
+ env = make_env(db=MockDB([make_stmt(first=None)]))
+ r = await worker.api_export_survey("missing", _get_req("/api/surveys/missing/export"), env)
+ assert r.status == 404
+
+ async def test_private_survey_hidden_from_non_owner(self):
+ token = _token(uid="someone-else")
+ env = make_env(db=MockDB([make_stmt(first=self._survey_row(is_public=0))]))
+ r = await worker.api_export_survey("s1", _get_req("/api/surveys/s1/export", token), env)
+ assert r.status == 404
+
+ async def test_returns_csv_content_type(self):
+ q = MockRow(id="q1", question_text="Comments?")
+ responses = [MockRow(id="r1", user_id="user-9", submitted_at="2024-01-01", username="bob")]
+ answers = [MockRow(response_id="r1", question_id="q1", answer_text="Nice!")]
+ env = make_env(db=MockDB([
+ make_stmt(first=self._survey_row()),
+ make_stmt(all_results=[q]),
+ make_stmt(all_results=responses),
+ make_stmt(all_results=answers),
+ ]))
+ r = await worker.api_export_survey("s1", _get_req("/api/surveys/s1/export"), env)
+ assert r.status == 200
+ assert r.headers["Content-Type"] == "text/csv"
+
+ async def test_csv_contains_header_and_rows(self):
+ q = MockRow(id="q1", question_text="Comments?")
+ responses = [MockRow(id="r1", user_id="user-9", submitted_at="2024-01-01", username="bob")]
+ answers = [MockRow(response_id="r1", question_id="q1", answer_text="Nice!")]
+ env = make_env(db=MockDB([
+ make_stmt(first=self._survey_row()),
+ make_stmt(all_results=[q]),
+ make_stmt(all_results=responses),
+ make_stmt(all_results=answers),
+ ]))
+ r = await worker.api_export_survey("s1", _get_req("/api/surveys/s1/export"), env)
+ assert "Comments?" in r.body
+ assert "bob" in r.body
+ assert "Nice!" in r.body
+
+ async def test_anonymous_response_labeled_in_csv(self):
+ q = MockRow(id="q1", question_text="Comments?")
+ responses = [MockRow(id="r1", user_id=None, submitted_at="2024-01-01", username=None)]
+ env = make_env(db=MockDB([
+ make_stmt(first=self._survey_row()),
+ make_stmt(all_results=[q]),
+ make_stmt(all_results=responses),
+ make_stmt(all_results=[]),
+ ]))
+ r = await worker.api_export_survey("s1", _get_req("/api/surveys/s1/export"), env)
+ assert "Anonymous" in r.body
\ No newline at end of file