Survey-Supported Access
How the unlock-via-survey feature actually works under the hood — the tenant-curated question builder, the double-pass AI moderation every question must clear before it's ever shown to a guest, per-router targeting and the cookie-reliability fix behind it, the attempt/session mechanics that keep one guest's answers from bleeding into another's, and how a priced question bills and reports exactly like an ad campaign. This is the standalone unlock-via-survey system (survey_questions, survey_unlock_tiers) — a separate, older feature also lets a single survey question ride along on an Aggressive-plan ad campaign purely for feedback; the two are covered apart below so they're never confused.
What it is, and the guest's path end to end
Survey-supported access is a third way for a guest to earn WiFi data alongside watching ads and redeeming vouchers: answer a short, venue-curated survey and unlock a data bundle. It's off by default and turned on per tenant from the ISN Portal (see panel 3). Once on, "Unlock with a survey" appears as an option on the guest's connect screen whenever at least one active, approved question exists for that location. The actual sequence:
| Step | What happens |
|---|---|
| 1. Tier list | GET /api/survey/unlock/tiers returns this tenant's active unlock tiers (empty array if surveys aren't turned on, or none are active — the guest UI simply doesn't offer the mode either way). |
| 2. Start | Guest picks a tier → POST /api/survey/unlock/start with tierId + their identifier. The server picks a shuffled set of eligible, approved questions (up to that tier's required_answers), creates a survey_unlock_attempts row (status:'open'), and gives the guest 15 minutes to answer. |
| 3. Answer | The guest answers each shown question in an on-page card (single/multi choice, star rating, yes/no, or free text) — nothing is sent to the server until every shown question has an answer. |
| 4. Submit | POST /api/survey/unlock/submit with the attemptId and answers. The server re-validates everything (see panel 7), records the responses, bills the sponsoring business or tenant per answer (panel 8), and grants the reward via the same markDeviceUnlocked() path every other unlock mechanism on this platform funnels through — tagged source: 'survey_unlock'. |
If a guest's age excludes some normally-eligible questions (see panel 5's age-targeting rule), the attempt simply shows fewer questions — actualRequired = min(tier.required_answers, eligible.length) — and the guest still gets the full reward for answering whatever remained eligible for them, rather than being blocked from the tier outright.
Building questions (Captive Dashboard → Surveys)
Question curation lives on the Captive Dashboard's Surveys tab (root business-dashboard.html — not the ISN Portal, which only holds the on/off switch and pricing; see panel 3). Only a tenant admin — or a business assigned admin rights — can reach it, gated by _requireSurveyTenantAdmin. The "Add Survey Question" modal:
| Field | What it controls |
|---|---|
| Question text | Up to 200 characters. |
| Question type | Single choice, Multiple choice, Star rating (1–5), Yes/No, or Short free-text. Choice types need at least 2 options, up to 10, each capped at 100 characters. |
| Business | Optional — assigns the question to one sponsoring business (must already exist under this same tenant), who then sees only its own aggregate results and answer list from its own dashboard login. Leave blank for the venue's own internal question. |
| Bundle | Optional — pools this question's price/budget with sibling questions in a named survey_question_bundles group instead of billing it on its own. |
| Budget (R) | Ignored if a bundle is selected. Blank = free/internal and uncapped. Priced at the tenant's own rate (panel 3), not typed per question. |
| Age Targeting | Optional min/max. A question with any age range set is hidden from a guest whose age isn't on file at all, not just one confirmed outside the range — same fail-closed rule ad age-targeting uses. |
| Run window | Optional start/end date. |
| Locations | Multi-select router picker — see panel 6. |
A newly created or edited question always starts as is_active:false, ai_review_status:'pending' — it can't go live until it clears AI review (panel 4). Editing a live question's text, options, or age range resets it back to pending automatically, since a content change invalidates whatever was approved before. A separate Pause/Resume toggle (PATCH .../survey-questions/:id/active) deliberately does not touch approval state, so an admin can pause and later resume an already-approved question without waiting for re-review. Deleting a question that already has guest responses works correctly — the server clears survey_question_responses first, then the question itself, after an earlier version of this endpoint hit a foreign-key violation (and, before that, deleted from the wrong table entirely) on exactly the "Live" questions an admin most wants to remove.
Turning it on and setting your rate (ISN Portal → Surveys)
The feature itself is switched on from the ISN Portal's own Surveys → Survey pricing panel, not the Captive Dashboard: an "Activate surveys" toggle (off by default) and a "Price per answer (R)" field (0.01–1000, blank = the platform default of R1.00). Both are read and saved through the same GET/POST /api/isn/bypass-settings endpoint that also carries owner-bypass and open-access settings — fields surveysEnabled and surveyPricePerAnswerZar.
This rate is tenant-wide, not per-question — it's what actually gets debited against every priced question's or bundle's own Rand budget (e.g. at the R1.00 default, a R50 budget stops the question after 50 answers). The same guard that protects every other access method applies here too: a tenant can't turn ad-supported bundles, vouchers, data plans, and surveys all off at once unless a verified 1Voucher/OTT gateway is connected — guests always need at least one way to connect.
A platform-wide, SaaS-admin-only utility sits alongside this: POST /api/isn/saas/survey-questions/audit-pii re-runs the current AI moderation rules against every already-approved question across every tenant and reports which ones would now fail — built so that tightening the moderation prompt (as happened in practice, see panel 4) can be checked retroactively. It only reports; nothing is auto-rejected or pulled from a live captive without a human reviewing that tenant's own flagged row first.
Unlock tiers & rewards
A tier (survey_unlock_tiers) is what a guest actually sees and picks on the connect screen — e.g. "5 questions → 100MB, valid 24h." Capped at 3 tiers per tenant. Each tier sets:
| Item | Details |
|---|---|
| Required answers | 1 to 50. |
| MB reward | 1 to 10,000 MB. |
| Duration | 0.5 hours to 8,760 hours (1 year). |
| Fair Usage Policy override (optional) | Its own throttle threshold/speed, same shared mechanism vouchers, data plans, and ad-unlock tiers already use. |
| Download/upload speed cap (optional, Mbps) | 0 inherits the tenant's default device speed. |
Each tier's own funnel stats (started / completed / expired / still in progress, plus a completion rate) are shown right on the tier card, drawn from survey_unlock_attempts and bounded to a recent 5,000-row snapshot so a busy tenant's dashboard stays fast — older attempts aren't deleted, just outside that view.
AI content moderation
Every question must clear automated review before it can ever go live — a guest never sees unreviewed content. The review checks four things in one pass, using an AI content-review model. If the review service is unavailable, the question is rejected outright rather than let through:
| Item | Details |
|---|---|
| Category | Alcohol, tobacco, gambling, weapons, drugs, sexual, financial, explosives, or none — matched using real-world brand recognition (Heineken, Savanna, Marlboro, Betway and similar are recognized as their category even with no literal keyword match), not simple keyword filtering. |
| Age floor | A literal check that this question's own configured minimum age is actually a number ≥ 18 when its category needs one; "no minimum set" never passes this check just because the subject seems harmless. |
| PII attempt | Every survey answer is promised anonymous, so the review specifically hunts for disguised attempts to collect a name, phone number, email, address, ID number, banking detail, or social-media handle, phrased indirectly to dodge an obvious keyword (the moderation prompt itself carries worked examples, e.g. "What's the unique thing your parents gave you at birth?" is flagged as really asking for a name). |
| Content safety | Hate speech, sexual content, illegal activity, injection-shaped text, or incoherent options for the stated question type. |
The final approve/reject decision is computed in application code from those four answers — never trusted as a single model-emitted verdict — because a small free-tier model reliably gets each narrow sub-question right far more often than it reliably aggregates all of them into one correct final call in the same breath. The whole review then runs twice, independently and in parallel, and both passes must agree on approval; any disagreement, timeout (20s), unreachable API, or malformed response fails the question closed (rejected), never silently through. A fallback list of three free-tier models is tried per pass (so a single rate-limited model can't reject every submission platform-wide), and extended "reasoning" output is explicitly disabled on every call — a real shipped bug had the model's internal chain-of-thought leaking into the response as plain prose instead of JSON, breaking the parser and failing nearly every submission closed until that was fixed.
On the dashboard, "Submit for Review" runs this live: approval auto-activates the question (is_active:true, "fix and publish" UX); rejection shows the AI's own one-sentence feedback so the admin knows exactly what to change.
Per-router targeting, and the cookie-reliability gap behind it
A question can be restricted to specific routers via its Locations picker (router_ids, a text array column — NULL/empty means every router the tenant owns, the untouched default for every question that predates this feature). It's enforced server-side inside POST /api/survey/unlock/start by the same _isAdRouterEligible helper ad campaigns use, and it fails closed: if the connecting router can't be identified at all, a location-restricted question is excluded rather than shown by default.
Identifying "which router" relies on, in priority order, an X-Router-Id request header, a nasid/routerId query parameter, or — only as a last resort — the presida_router_id cookie set at the guest's initial /hotspot handshake. That cookie lives for only 10 minutes. A real gap found and fixed in 2026-09: a guest whose time on the connect screen ran past that 10-minute window lost router identification entirely, so a location-scoped survey question (or ad campaign) would silently stop matching for them — not because their router had changed, but because the one piece of evidence proving which router they were on had simply expired. The fix was to stop depending on the cookie surviving: the guest page's own detectRouterId() now reads a value login.html already persisted to sessionStorage at login (which lasts the whole browser session, no 10-minute ceiling) and sends it explicitly as the X-Router-Id header on every survey-start call.
Note this only scopes which questions are eligible once a tier is chosen — GET /api/survey/unlock/tiers (the tier list itself) isn't router-filtered.
Session integrity, anonymity & the 2026-09 IDOR fix
Each unlock attempt is keyed to a salted SHA-256 hash of the guest's identifier, not the identifier itself, and expires exactly 15 minutes after /start — an expired or already-submitted attempt is rejected with a 409 on /submit, never silently re-processed. /submit only ever accepts answers for the exact question IDs that attempt actually showed (question_ids_shown); a tampered client-side payload can't invent extra questions or skip the server's own random selection. Every answer is then re-sanitized and re-capped server-side strictly by its real question type regardless of what the client sent — free text truncated to 300 characters, choices capped to 100 characters/10 options, star ratings clamped to 1–5, yes/no coerced to a literal string — and this server-side pass, not any client-side validation, is the actual defense against a malicious free-text answer breaking a dashboard page.
Anonymity is structural, not just promised: survey_question_responses never stores a name, phone number, email, or ID — only a hashed device fingerprint and a hashed client IP for abuse/rate-limiting signal, neither of which is ever surfaced to the tenant or the sponsoring business in results. A dedicated endpoint even exists to prove that out (results are returned with "no guest identity/device/IP returned").
Security-audit finding, fixed 2026-09: neither /start nor /submit originally checked that the calling browser session actually owned the identifier it was unlocking data for — every other guest-grant endpoint on the platform (voucher redeem, bundle grant, data-plan purchase) already had this check, but survey-unlock didn't. That gap meant anyone reachable to the captive API could start and complete a survey against an identifier they didn't own, permanently polluting that guest's usage history and draining a real advertiser's budget on fabricated responses. Both endpoints now call the same _sessionOwnsIdentifier() check as everywhere else. Every survey endpoint is also rate-limited to 60 requests per 5 minutes per IP.
Billing & revenue tracking — treated like an ad view
A sponsoring business (or the tenant itself, for an unassigned/internal question) pays a flat rate per delivered answer, set tenant-wide in the ISN Portal (panel 3, default R1.00). The instant a question's or bundle's answers_delivered × price_per_answer reaches its own Rand budget, it auto-pauses (is_active:false) — checked inline on every submit, not on a delayed sweep. The same 75%/100% budget-milestone pattern already shipped for ad campaigns applies here: a milestone email fires at each threshold, plus a "paused — budget exhausted" email at 100%, sent to the sponsoring business's own login email where one is assigned, or the tenant/owner email otherwise — fired fire-and-forget so a slow or failed send can never block a guest's own unlock.
No payment gateway is involved at all. Exactly like ad-campaign revenue, survey spend is only ever tracked — on My Revenue and the Captive Dashboard's own reporting — never actually collected by the platform; invoicing or collecting from the sponsoring business is entirely between the tenant and that business. Survey revenue appears as its own bucket (surveyRevenue) alongside ad/voucher/data-plan/subscription revenue in the daily revenue trend and every export, with one explicit limitation: unlike ad views, voucher redemptions, or data-plan sales, survey revenue has no per-router attribution — it isn't broken out in the Revenue-by-Router view. If concurrent submissions ever momentarily push billed answers slightly past a budget, the reported daily split is proportionally scaled back down so the reported total can never exceed the real capped budget.
Limits, retention & things worth knowing
| Limit | Value |
|---|---|
| Unlock tiers per tenant | 3 maximum |
| Required answers per tier | 1–50 |
| Reward per tier | 1–10,000 MB |
| Tier duration | 0.5h–8,760h (1 year) |
| Question text | 200 characters |
| Options per choice question | 2–10, 100 characters each |
| Free-text answer | 300 characters |
| Attempt answer window | 15 minutes from Start |
| Router-id freshness (cookie fallback only) | 10 minutes — see panel 6 for why this no longer matters in practice |
Retention: survey_question_responses rows are purged automatically on a background sweep keyed to tenants.survey_response_retention_days (defaulting to 365). In practice every tenant currently runs on that 365-day default — the column exists and is already what the sweep reads, but no dashboard control for a venue to set its own shorter or longer window has shipped yet, so don't promise a custom retention period until one does.
Don't confuse this with the ad-attached survey feature: a single survey question can also ride along on an Aggressive or Aggressive+Skip Disabled ad campaign (business_ads.survey_enabled, its own survey_responses table, POST /api/survey/respond). That older, still-live feature collects feedback on the ad itself and never grants any data — it's a completely separate system from everything on this page, with its own tables and its own endpoints, kept deliberately unconnected so neither feature can break the other.
