CAC System Runbook

CAC-OPS-001 · Version 1.0 · May 2026

One worker. One KV namespace. One R2 bucket. Data-only changes for everything except new routes.

Worker backend is frontend-agnostic. Any frontend (Eleventy, Astro, plain HTML) that POSTs JSON with course_id, event_slug, form_type, hrdc_option + form fields to the worker route will work.


How the system connects

URL (Eleventy page)
  → form CONFIG object (course_id, event_slug)
    → POST to worker
      → worker reads KV using those values
        → renders PDFs → sends email

The worker never knows about URLs. It only knows course_id and event_slug.


01 — Add a subdomain for a new TP (same worker, same courses)

What this is: A training provider gets their own subdomain e.g. newtp.claritysystems.work with their logo and branding on all documents.

Steps:

  1. DNS — Cloudflare dashboard → DNS → Add record

  2. Worker route — Workers & Pages → trainingbiz-admin-clerk → Settings → Domains & Routes → + Add

  3. KV — domain entry Key: domain:newtp.claritysystems.work

    {
      "tp_key": "newtp",
      "payment_paths": ["grant"],
      "notify_telegram_chat_id": "THEIR_CHAT_ID"
    }
    
  4. KV — TP entry Key: tp:newtp

    {
      "name": "TP Legal Name Sdn Bhd",
      "company_id": "MYCOID_NUMBER",
      "address": "Full address",
      "contact_name": "Contact Person Name",
      "contact_email": "[email protected]",
      "contact_phone": "01X-XXX XXXX",
      "bank_name": "Bank Name",
      "bank_account_name": "Account Name",
      "bank_account_number": "XXXX XXXX XXXX",
      "sst_number": "SST-NUMBER",
      "logo_url": "https://pub-XXXX.r2.dev/logo-newtp.png",
      "notify_telegram_chat_id": "CHAT_ID"
    }
    
  5. R2 — Upload TP logo to cac-assets bucket

  6. Eleventy form page — Copy /resellers/newrise/sales-training/enquiry/index.html

Test: Submit form on new subdomain. Check email for PDFs with TP logo.


02 — Add a new course

What this is: A new training programme with its own schedule, pricing, and event dates.

Steps:

  1. KV — course entry Key: course:{course_id} (e.g. course:communications-101)

    {
      "title": "Full Course Title",
      "duration_label": "2 Days",
      "currency": "MYR",
      "programme_desc": "One paragraph description.",
      "hrdc_programme_number": null,
      "corporate": {
        "base_price": 8000,
        "direct_discount_pct": 10,
        "grant_surcharge_pct": 10
      },
      "public": {
        "rate_per_seat": 1500,
        "stripe_link": "https://buy.stripe.com/XXXX"
      },
      "pax_min": 15,
      "pax_max": 30,
      "includes_note": "Materials, certificate, meals.",
      "logistics_note": "Projector, whiteboard, WiFi.",
      "hrdc_block": "<p>HRDC claimable under PROLUS.</p>",
      "terms": {
        "corporate_direct": "50% deposit on confirmation.",
        "corporate_grant": "Full payment before training date.",
        "public_grant": "Full payment before training date."
      },
      "email_copy": {
        "corporate_grant": {
          "subject": "Your Training Proposal — ",
          "body_intro": "Please find attached your proposal documents."
        },
        "corporate_direct": {
          "subject": "Your Training Proposal — ",
          "body_intro": "Please find attached your proposal documents."
        }
      }
    }
    
  2. KV — schedule entry Key: schedule:{course_id}

    {
      "day1_title": "Day 1 Theme",
      "day2_title": "Day 2 Theme",
      "day1": [
        { "time": "09:00 – 09:15", "topic": "Topic name" },
        { "time": "09:15 – 10:30", "topic": "Topic name" },
        { "time": "10:30 – 10:45", "topic": "Morning Break" },
        { "time": "10:45 – 13:00", "topic": "Topic name" },
        { "time": "13:00 – 14:00", "topic": "Lunch Break" },
        { "time": "14:00 – 15:30", "topic": "Topic name" },
        { "time": "15:30 – 15:45", "topic": "Afternoon Break" },
        { "time": "15:45 – 16:45", "topic": "Topic name" },
        { "time": "16:45 – 17:00", "topic": "Topic name" }
      ],
      "day2": [
        { "time": "09:00 – 09:15", "topic": "Topic name" },
        { "time": "09:15 – 10:30", "topic": "Topic name" },
        { "time": "10:30 – 10:45", "topic": "Morning Break" },
        { "time": "10:45 – 13:00", "topic": "Topic name" },
        { "time": "13:00 – 14:00", "topic": "Lunch Break" },
        { "time": "14:00 – 15:30", "topic": "Topic name" },
        { "time": "15:30 – 15:45", "topic": "Afternoon Break" },
        { "time": "15:45 – 16:30", "topic": "Topic name" },
        { "time": "16:45 – 17:00", "topic": "Topic name" }
      ]
    }
    
  3. KV — event entry Key: event:{course_id}:{slug} (e.g. event:communications-101:kl-jun-2026)

    {
      "date_day1": "Tuesday, 10 June 2026",
      "date_day2": "Wednesday, 11 June 2026",
      "time": "09:00 – 17:00",
      "tp_key": "newrise"
    }
    
  4. Eleventy form page — Copy /sales-training/enquiry/index.html

  5. Trainer PDF — Upload to R2 as trainer_{trainer_key}.pdf if required. Add trainer_key to event KV entry.

Test: curl POST with new course_id and event_slug. Check email.


03 — New domain for a different TP with different frontend design

What this is: A fully separate domain (e.g. training.newtp.com) with its own Eleventy site and visual identity, but sharing the same worker backend.

Steps:

  1. DNS — Point training.newtp.com to Cloudflare (add site to Cloudflare or use CNAME to worker)

  2. Worker route — + Add route: training.newtp.com/*

  3. KV entries — Same as Step 01 (domain + tp entries) but with key domain:training.newtp.com

  4. New Eleventy project — Copy the existing site structure

  5. R2 — Upload TP logo. The same cac-assets bucket is shared — just use a unique filename.

Note: The worker is shared. The frontend is separate. PDFs will use the TP logo from tp:{id}.logo_url. No worker code change required.


Form CONFIG reference

Every enquiry form page must have this at the top of its script:

const CONFIG = {
  course_id:  "your-course-id",
  event_slug: "your-event-slug"
};

These two values are the only connection between the URL and the worker data.


Deploy reference

What changed How to deploy
KV data Cloudflare dashboard → KV → edit. Instant.
R2 file (template, logo, PDF) Cloudflare dashboard → R2 → upload. Instant.
Worker route Dashboard → Worker → Domains & Routes → + Add. Instant.
Worker code (index.js) git push → GitHub Actions auto-deploys. ~2 min.
Eleventy page / form git push → GitHub Actions auto-deploys. ~2 min.

Never: Use dashboard editor for index.js. Use wrangler locally. Direct curl deploy.