Implementation guide

How the dev team connects the two trackers to the real backend.

Overview

Two static HTML trackers (Matchmaker, Ask ACG) run today against seed JSON plus browser storage . All data access is in assets/adapter.js (ACG.api) . Going live means: implement the REST contract below, store the three collections, and rewrite the adapter's function bodies to call it. No other front-end file changes.

AssumptionsThe real site looks like Next.js (App Router) with MongoDB, so the examples use Route Handlers and the Node driver. The contract itself is stack-neutral. Auth, the password gate and CORS are yours to apply.

REST contract

JSON over HTTP , base path /api. Collections return { "items": [...] } (threads also total). Errors: { "error": "..." } or { "errors": [...] } with the status codes listed .

MethodPathPurposeSuccess
GET/api/configEnvironments, time zones, statuses, issue tags, base URLs200 config object
GET/api/matchmaker/schemaWizard questions and option lists200 schema object
GET/api/matchmaker/casesAll case definitions200 {items:[...]}
GET/api/matchmaker/cases/:idOne case200 / 404
POST/api/matchmaker/casesCreate a case (server assigns id)201 saved case
PUT/api/matchmaker/cases/:idReplace a case200 saved case
DELETE/api/matchmaker/cases/:idDelete a case and its runs204
GET/api/matchmaker/runsAll runs; optional ?case_id=200 list
POST/api/matchmaker/runsLog a run201 saved run
PUT/api/matchmaker/runs/:idEdit a run200 saved run
DELETE/api/matchmaker/runs/:idDelete a run204
GET/api/askacg/threadsThreads with pairs; filters below200 {items,total}
GET/api/askacg/threads/:idOne thread200 / 404
POST/api/askacg/threadsCreate a manual thread201 saved thread
PUT/api/askacg/threads/:idUpdate; backend logs: annotations only200 / 422
DELETE/api/askacg/threads/:idDelete a manual thread204 / 409 for source backend

Thread list filters (all optional): env (comma list), source, user_type, flagged, from/to (UTC instants), q, page, limit, min_pairs, max_pairs, plus status/verdict/rating on pairs.

Data schemas

IDs are server-generated. Instants are UTC ISO strings . Environment is a list on cases, a single value on runs and threads.

Matchmaker case

{
  "id": "MM-0001",
  "title": "Low budget, nationwide",
  "environments": ["dev", "review"],
  "status": "active",
  "type": "regression",
  "answers": { "budget": 20000, "gpa": 3.4, "sat": 1200, "distance": null,
               "campus_setting": ["city"], "priorities": ["affordability"] },
  "expected_count": 42,
  "must_include": ["Example State University"],
  "must_exclude": [],
  "notes": "",
  "created_at": "2026-10-31T18:12:00Z",
  "updated_at": "2026-10-31T18:12:00Z"
}

Matchmaker run

{
  "id": "RUN-0001", "case_id": "MM-0001", "environment": "dev",
  "ran_at": "2026-10-31T18:12:00Z",
  "actual_count": 42,
  "include_checks": { "Example State University": true },
  "exclude_checks": {},
  "result": "pass",              // pass | fail | blocked | skipped
  "overridden": false, "override_reason": "",
  "tester": "", "build_version": "", "data_version": "", "notes": ""
}

Ask ACG thread

{
  "id": "TH-0001", "thread_uid": "c017d1f4-...", "source": "backend",   // backend | manual
  "environment": "live",
  "created_at": "2026-10-31T18:12:00Z",
  "user": { "type": "visitor", "email": "", "nickname": "", "session_id": "v-9f3a21..." },
  "tester": "", "build_version": "", "data_version": "",
  "flagged": false, "notes": "",
  "pairs": [{
    "id": "P-1", "ts": "2026-10-31T18:12:00Z",
    "question": "What is Early Decision?", "answer": "...",
    "expected_answer": "", "status": "showed_correct", "status_as_of": "2026-10-31",
    "verdict": "pass",            // pass | fail | null
    "rating": 5, "issue_tags": [], "notes": "",
    "status_history": [{ "at": "...", "from": "not_reviewed", "to": "showed_correct", "as_of": "2026-10-31", "by": "" }]
  }]
}

Pass rule, as implemented in the UI and worth repeating server-side: pass = actual count equals expected AND every include/exclude check ticked; a manual override needs a reason.

Storage (MongoDB)

Three collections; Ask ACG pairs are embedded because a thread is always read whole. Convert ISO strings to Date on write and back on read. Indexes :

// db.mm_cases      one document per case (as above)
// db.mm_runs       one per run; case_id references mm_cases.id
// db.aa_threads    one per thread, pairs embedded (a thread is always read whole)
db.mm_cases.createIndex({ id: 1 }, { unique: true });
db.mm_runs.createIndex({ id: 1 }, { unique: true });
db.mm_runs.createIndex({ case_id: 1, ran_at: -1 });
db.aa_threads.createIndex({ id: 1 }, { unique: true });
db.aa_threads.createIndex({ thread_uid: 1 }, { unique: true });
db.aa_threads.createIndex({ created_at: -1 });
db.aa_threads.createIndex({ "user.email": 1 });
db.aa_threads.createIndex({ environment: 1, source: 1 });
db.aa_threads.createIndex({ question_text: "text", answer_text: "text" }); // optional keyword search

Server rules

  • Backend-written threads are read-only except annotations; DELETE returns 409 .
  • Validate everything again on the server , including "showed correct needs an as-of date".
  • Deleting a case deletes its runs.
  • Sign-in required on every route ; emails are personal data .
  • Use same-origin hosting or add CORS .

Next.js handler examples

Route Handlers (docs) with the Node driver (docs). Sketches, not drop-in code: getDb, nextId, toIso, fromIso and validateThread are yours to write.

// app/api/askacg/threads/route.js   (Next.js App Router Route Handler)
import { NextResponse } from "next/server";
import { getDb } from "@/lib/mongo";        // your existing connection helper

export async function GET(req) {
  const p = new URL(req.url).searchParams;
  const q = {};
  if (p.get("env")) q.environment = { $in: p.get("env").split(",") };
  if (p.get("source")) q.source = p.get("source");
  if (p.get("user_type")) q["user.type"] = p.get("user_type");
  if (p.get("flagged") === "true") q.flagged = true;
  if (p.get("from") || p.get("to")) {
    q.created_at = {};
    if (p.get("from")) q.created_at.$gte = new Date(p.get("from"));
    if (p.get("to"))   q.created_at.$lt  = new Date(p.get("to"));
  }
  const limit = Math.min(+p.get("limit") || 100, 500), skip = ((+p.get("page") || 1) - 1) * limit;
  const db = await getDb();
  const [items, total] = await Promise.all([
    db.collection("aa_threads").find(q, { projection: { _id: 0 } }).sort({ created_at: -1 }).skip(skip).limit(limit).toArray(),
    db.collection("aa_threads").countDocuments(q),
  ]);
  return NextResponse.json({ items: items.map(toIso), total });   // toIso: Date -> ISO string
}

export async function POST(req) {
  const body = await req.json();
  const errors = validateThread(body);                 // see "Validate on the server"
  if (errors.length) return NextResponse.json({ errors }, { status: 422 });
  const db = await getDb();
  const doc = { ...body, source: "manual", id: await nextId(db, "TH"), created_at: new Date(body.created_at) };
  await db.collection("aa_threads").insertOne(doc);
  return NextResponse.json(toIso(doc), { status: 201 });
}
// app/api/askacg/threads/[id]/route.js
const ANNOTATION_FIELDS = ["tester","build_version","data_version","flagged","notes"];
const PAIR_ANNOTATIONS  = ["expected_answer","status","status_as_of","verdict","rating","issue_tags","notes","status_history"];

export async function PUT(req, { params }) {
  const { id } = await params;                       // params is a Promise in recent Next.js versions
  const body = await req.json(), db = await getDb();
  const cur = await db.collection("aa_threads").findOne({ id });
  if (!cur) return NextResponse.json({}, { status: 404 });
  let next = body;
  if (cur.source === "backend") {                    // keep identity, Q, A and timestamps from the stored copy
    next = { ...cur };
    ANNOTATION_FIELDS.forEach(k => next[k] = body[k]);
    next.pairs = cur.pairs.map(p => { const b = body.pairs.find(x => x.id === p.id) || {}; const o = { ...p }; PAIR_ANNOTATIONS.forEach(k => o[k] = b[k]); return o; });
  }
  const errors = validateThread(next);
  if (errors.length) return NextResponse.json({ errors }, { status: 422 });
  await db.collection("aa_threads").replaceOne({ id }, fromIso(next));
  return NextResponse.json(next);
}

export async function DELETE(_req, { params }) {
  const { id } = await params, db = await getDb();
  const cur = await db.collection("aa_threads").findOne({ id });
  if (!cur) return new Response(null, { status: 404 });
  if (cur.source === "backend") return NextResponse.json({ error: "Backend logs cannot be deleted." }, { status: 409 });
  await db.collection("aa_threads").deleteOne({ id });
  return new Response(null, { status: 204 });
}

Adapter swap

Replace the body of assets/adapter.js with the following. Names and return shapes match the mock exactly .

// assets/adapter.js  (after the swap; same function names, same return shapes)
(function () {
  var ACG = window.ACG, API = "/api";
  function call(method, path, body) {
    return fetch(API + path, {
      method: method, credentials: "same-origin",
      headers: body ? { "Content-Type": "application/json" } : undefined,
      body: body ? JSON.stringify(body) : undefined
    }).then(function (r) {
      if (r.status === 204) return true;
      return r.json().catch(function () { return {}; }).then(function (j) {
        if (!r.ok) throw new Error((j && (j.error || (j.errors || []).join("; "))) || ("HTTP " + r.status));
        return j;
      });
    });
  }
  var api = ACG.api = { mode: "live" };
  api.getConfig = function () { return call("GET", "/config"); };
  api.matchmaker = {
    getSchema:  function ()    { return call("GET", "/matchmaker/schema"); },
    listCases:  function ()    { return call("GET", "/matchmaker/cases").then(function (r) { return r.items; }); },
    getCase:    function (id)  { return call("GET", "/matchmaker/cases/" + id); },
    saveCase:   function (c)   { return c.id ? call("PUT", "/matchmaker/cases/" + c.id, c) : call("POST", "/matchmaker/cases", c); },
    deleteCase: function (id)  { return call("DELETE", "/matchmaker/cases/" + id); },
    listRuns:   function (cid) { return call("GET", "/matchmaker/runs" + (cid ? "?case_id=" + encodeURIComponent(cid) : "")).then(function (r) { return r.items; }); },
    saveRun:    function (r)   { return r.id ? call("PUT", "/matchmaker/runs/" + r.id, r) : call("POST", "/matchmaker/runs", r); },
    deleteRun:  function (id)  { return call("DELETE", "/matchmaker/runs/" + id); }
  };
  api.askacg = {
    listThreads:  function ()   { return call("GET", "/askacg/threads?limit=500").then(function (r) { return r.items; }); },
    getThread:    function (id) { return call("GET", "/askacg/threads/" + id); },
    saveThread:   function (t)  { return t.id ? call("PUT", "/askacg/threads/" + t.id, t) : call("POST", "/askacg/threads", t); },
    deleteThread: function (id) { return call("DELETE", "/askacg/threads/" + id); }
  };
  // Mock-only helpers: keep harmless stubs so the pages do not break.
  api.resetSeed  = function () { return Promise.resolve(true); };
  api.seedStatus = function () { return Promise.resolve({ stale: false, sample: false, notes: [] }); };
})();

Then remove the "Reset to seed data" buttons or leave them as no-ops, and delete data/*.json except what you still want as documentation. data/config.json becomes GET /api/config. Export needs no server work.

Linked references open in a new tab. They point to the official documentation of each technology.