Questify

3.9.0

Released

Features

  • A quest owner can now turn on Collect evidence, and once a task on that quest is done anyone in the party can add a photo of it, or check in at the task's location. A model writes one short line describing what the photo shows; it describes, never judges, and never blocks or undoes the completion. A check-in records only whether you were there, not where you were. Evidence stays private to the quest's party, even on a public quest, and whoever added it (or the quest owner) can delete it, which also deletes the photo. Captions cost nothing on the free tier but count toward its daily AI limit; past that limit the photo is still saved, just without a caption. On a paid persona they are charged to whoever added the photo. Behind NEXT_PUBLIC_FEATURE_TASK_EVIDENCE, off by default (#551).

  • Links to Questify now unfurl in Slack, Discord, X and Reddit with a branded preview card, and a shared public quest or Quest Jam gets a card of its own naming it: a quest shows its title, task count and owner, a jam its title, theme, status and dates (#656). A private quest, or a jam not yet live, shows the generic card even when its owner shares the link, because every card is drawn as a signed-out visitor would see the page.

  • Questify now measures where people drop out of quest creation, onboarding, the AI Coach and challenges, so the beta can be steered by what visitors actually do (#652). Counting uses Plausible, which is cookieless and needs no consent banner. It sends nine funnel events: quest_pack_selected, quest_create_started / quest_create_completed, onboarding_step_completed / onboarding_skipped, investigation_requested, coach_interview_started / coach_interview_completed and challenge_joined. Events carry counts and catalog slugs only, never names, account ids or quest content. Every URL is redacted before it is sent: invitation tokens, recap share ids and UUIDs in the path become placeholders, and only campaign query parameters are kept, so an invitation link never reaches Plausible. Steps a server action decides (a quest actually created, an onboarding step actually saved) are sent from the server after the write lands, because those actions redirect and a submit-time event would count failures. /privacy names Plausible under third-party services and says what it receives. Nothing is sent unless NEXT_PUBLIC_ANALYTICS_DOMAIN is set.

  • You can download a copy of your data from /settings/account. Download my data saves a JSON file, served by the new GET /api/me/export, of your data: profile and settings, the quests you own (including ones you deleted) with their tasks, deadlines and AI investigations, your comments, memberships, attendance, themes, achievements, challenge and jam entries, recaps, feedback and subscription status. Files you uploaded are listed by name and size rather than included, and other members' tasks and comments on a Party Quest you belong to are not yours to export. The route answers 401 when signed out and never caches the file. The privacy policy gains a "Getting a copy of your data" section, so access, like deletion since 3.8.0, no longer needs an email to us (#654).

Fixes

  • The Send feedback and quest Check In dialogs now close on Escape, keep Tab inside the dialog, and give focus back to whatever opened them when they close, like the app's other dialogs already did (#648).

Security

  • Signed-out callers can no longer call five signed-in-only RPCs, and signed-in callers can no longer read another account's free-tier usage (#653). complete_quest(), dismiss_stall_notification(), snooze_stall_notification(), log_stall_check_in() and get_dashboard_stats() lose anon (each did nothing without a user, but answered rather than refusing). free_tier_actions_used_for(), free_tier_actions_remaining_for(), free_tier_daily_action_limit_for() — which took any user id — and the internal trigger guard assert_quest_accepts_task_writes() lose authenticated: nothing in the app or the worker called them, and the definer functions that do are unaffected. Four authenticated_security_definer advisor findings left supabase/advisor-allowlist.txt with them. AGENTS.md, the add-migration skill, docs/schema.md and docs/ops/database-advisors.md now say a client-callable function needs an explicit grant (#653).

Technical changes· 13 entries for database, tooling, CI, tests and docs

Database

  • 20261203120000_task_evidence.sql adds task completion evidence. quests.evidence_enabled is the owner's opt-in (default off, now also returned by get_my_active_quests() and get_quest_with_tasks()), and task_evidence holds either a photo in the new private task-evidence bucket (10 MB, JPEG/PNG/WebP) or the result of a location check-in: whether the device was inside the task anchor's radius and the distance rounded to 10 m. The coordinates themselves are never stored. Rows are written only through add_task_photo_evidence() and add_task_location_evidence(), which require quest access, the opt-in and a done task (and location consent for a check-in); the table has no client INSERT or UPDATE grant, so a caption cannot be self-written. Evidence is readable by the quest party only, never anon even on a public quest, and deletable by its submitter or the quest owner. Photo captions run through a lease-bound queue modeled on the stall jobs (claim_/charge_/reprice_/complete_/fail_/skip_task_evidence_caption), charged to the submitter under the new evidence_caption ledger kind with the usual one-image surcharge, never on DeepSeek, and refunded on reclaim or when the evidence is deleted before its caption is delivered (a delivered caption keeps its charge). refund_ai_credits() now takes a per-reference transaction lock, so two refunds of the same reference racing each other (the caption worker's failure path against a delete or a reclaim, or the stall jobs' equivalents) return the charge once rather than twice. A photo's caption also records a free-tier action (free_tier_ai_actions.kind gains evidence_caption), so it counts toward the daily allowance; at the limit the photo is recorded with its caption skipped as daily_limit. Also adds find_orphaned_task_evidence_objects() and a task_evidence section in export_my_data(). Nothing in it can block or reverse a completion: evidence attaches after the task is done, even when that completion just finished the quest (#551).

  • 20261201120000_export_my_data.sql adds export_my_data(), which returns everything Questify holds that belongs to the caller as one jsonb document. It is SECURITY DEFINER, executable by authenticated only, and refuses (42501) a caller with no auth.uid(), so the service role cannot run it either. It is a function and not a set of reads from the route for three reasons. quests.due_at and tasks.due_at are withdrawn from client reads (#570), and a deadline is the caller's own data. One statement is one snapshot, with no PostgREST max_rows page to cut a list short. And the profile, feedback, challenge-attempt and jam-entry SELECT policies also admit staff, so a staff member reading as themselves would have exported other people's rows. Every section is keyed on auth.uid() explicitly instead of on RLS. The document covers the account email, profile and settings; every quest the caller owns, soft-deleted ones included, with every task and deadline; investigations on those quests, and those the caller started on a quest they can still open; their comments, deleted ones included, since the body is still stored; attachment metadata; seats, attendance, year themes, achievements, challenge attempts, jam entries, recurrence templates with their checklists (a step removed from the list included and marked, since a template's list can differ from the cycles already minted), recaps, guilds, feedback and subscriptions. It leaves out other members' content and other accounts' ids (confirmed_by, invited_by_id, initiated_by_id), the unsubscribe token, investigation pricing, feedback triage notes and storage paths. 20261201130000_export_recurrence_template_tasks.sql adds the quest_recurrence_template_tasks section, which the PR #931 review found missing. supabase/advisor-allowlist.txt records the definer grant (#654).

  • A new function in public is now private until its migration grants it (#653). 20261202120000_function_execute_default_deny.sql removes the two default grants that made every function callable over /rest/v1/rpc the moment it was created: Postgres' EXECUTE for PUBLIC (revoked from role postgres's global defaults) and Supabase's direct grant to anon and authenticated in public. service_role keeps its default, so the worker and trusted server code are unaffected. Existing functions keep their grants, because default privileges apply only to new objects and create or replace preserves ACLs; a function dropped and recreated comes back private. supabase/tests/function_execute_grants.sql proves the new default with a function created in the test, and pins with set_eq every function anon can execute (14 SECURITY DEFINER functions behind signed-out pages, plus 20 pure invoker helpers that run inside constraints and triggers) and every SECURITY DEFINER function authenticated can execute (104). Seven pgTAP files grant EXECUTE on their own pg_temp / tests helpers, which are now private like any other new function (#653).

Tooling & Configuration

  • NEXT_PUBLIC_FEATURE_TASK_EVIDENCE (default false) gates task completion evidence, registered in .env.example, env.ts, the web Dockerfile, docker-compose.yml, turbo.json and the Playwright web server. The worker gains an evidence-captions loop (EVIDENCE_CAPTION_POLL_INTERVAL_MS, default 15 s) that runs whenever an image-capable provider key is set; it is not flag-gated, because its queue is empty unless someone attaches a photo. The attachment reaper now also sweeps the task-evidence bucket, and account deletion removes the account's evidence photos (#551).

  • NEXT_PUBLIC_ANALYTICS_DOMAIN (optional, production only) turns on Plausible analytics (#652). Unset, the default, means AnalyticsScript renders no script and trackServerEvent() sends nothing, so local, preview, CI and Docker builds stay silent. It is registered in .env.example, apps/web/src/lib/env.ts (getAnalyticsDomain()), the web Dockerfile ARG/ENV, the docker-compose.yml build args and turbo.json's build and dev env lists.

  • packages/ui has a shared Dialog primitive, @questify/ui/components/dialog (#648). Dialog is the overlay: role="dialog", aria-modal, aria-labelledby, backdrop click and Escape through one onDismiss, and focus handling. DialogPanel is the height-bound panel (max-h-full, a size prop for the max width, asChild for a <form>). DialogHeader (optional) and DialogFooter are pinned, and DialogBody is the one region that scrolls (min-h-0 flex-1 overflow-y-auto, with a required data-testid). The feedback, investigation prompt, check-in, complete-quest, delete-quest, location consent and Quest Pack preview dialogs moved onto it and dropped their hand-written copies of that markup. useModalFocus moved from apps/web/src/lib/a11y/ to @questify/ui/hooks/use-modal-focus, since the primitive needs it and it has no app dependencies; its callers import it from there. The model persona picker keeps its whole-panel scroll for now.

CI/CD

  • The worker deploy in release.yml now records itself in Sentry: once the worker is verified running, it adds the worker project to release questify@<version> and records a production deploy scoped to worker through Sentry's API (sentry-cli would also list it as a deploy of web). Before, only the web build's plugin recorded a deploy, so worker showed none and joined the release only on its first error. The step is skipped without SENTRY_AUTH_TOKEN / SENTRY_ORG and only warns if Sentry is unreachable; scripts/worker-deploy.test.mjs pins it (#657).

Tests

  • Task evidence is pinned (#551). supabase/tests/ai_credit_refund_concurrency.sql shows a second refund of the same reference waiting for the first and returning nothing. supabase/tests/task_evidence.sql (52 assertions) covers the attach RPCs' refusals, a check-in storing only its result, evidence on the task that just completed its quest, the caption persona by tier, the one-image surcharge, the lease-bound charge, complete, fail and skip, and the refunds on reclaim and delete; rls.sql adds the party/outsider/anon matrix on a public quest. Worker Vitest covers the caption job's success, skip, refund, reprice, superseded and unreachable-host paths; web Vitest covers the evidence section, its Server Actions, the opt-in switch with the flag on and off, the TaskManager wiring and account deletion's photo cleanup. e2e/task-evidence.spec.ts (live fixture only, like the attachment suite) covers the flag-off state, the owner's opt-in on the quest form, and a real upload to the private bucket on a done task followed by its removal.

  • The analytics layer is pinned (#652). analytics.server.test.ts covers the Events API request: the forwarded User-Agent and client IP, the Referer or fallback URL, dropped empty properties, the unset-domain no-op, and a failed send that never throws. analytics-script.test.tsx covers the script's absence when the domain is unset, the manual build and the pre-load call queue. analytics-url.test.ts pins the URL redaction, and analytics-pageviews.test.tsx pins one redacted page view per navigation. Every call site asserts its payload, and that a refused or failed outcome sends nothing: the onboarding and createQuestAction suites (a rolled-back quest is not counted), the Quest Pack browser and form, the New Quest creator, the coach interview and "Draft it for me" (once per interview), the investigation request, and a new challenge-start-form.test.tsx. src/test/analytics.ts adds captureAnalytics() for component tests.

  • supabase/tests/export_my_data.sql (26 assertions) shows export_my_data() refusing anon, the service role and a session with no user. It exports soft-deleted quests and both kinds of deadline, and only the right investigations: those on the caller's quests, and those the caller started on a quest they can still open, with none from another member or from a quest the caller cannot open. It keeps the caller's deleted comments and none of anyone else's, and each template owner's recurring Quest checklist with its removed steps and none of anyone else's, and leaves out the attendance confirmer's id, the unsubscribe token and feedback triage notes. A staff account whose role reads other people's profiles and feedback exports only its own. rls.sql pins the function's grants. Vitest covers the route's 401, the download headers and file name, the 500 without the database's message on a failed or empty export, getMyDataExport() and the filename helper, the settings link and the privacy section; the web coverage floors rise to 96.0 / 92.2 / 95.6 / 96.0 (#654).

  • ui-dialog.test.tsx pins the Dialog primitive's structure, since jsdom has no layout: the classes that carry the bound, the actions outside the scroll region, overlay class overrides, every panel size, asChild on a form, backdrop and Escape dismissal, and focus moving in and back (including to restoreFocusRef). The feedback and check-in suites gain Escape cases, and the useModalFocus suite moved to ui-modal-focus.test.tsx with the hook (#648).

Documentation

  • docs/analytics.md is the event catalogue (#652): every event, which side sends it, when, and its properties. It also covers why Plausible, how URLs are redacted and which routes count as secrets, the property rules (no personal data or user content), the Plausible goal and property setup, the roadmap funnels each event serves, and how to add an event. docs/ops/deployments.md lists NEXT_PUBLIC_ANALYTICS_DOMAIN as a production-only Vercel variable.

  • docs/schema.md gains a "Data export" section listing each key of the export, what it holds and what is deliberately left out, plus the migration-order row and the RLS-coverage note. docs/api-endpoints.md documents GET /api/me/export, its headers and its error codes (#654).