2.20.0
Released
Features
With
NEXT_PUBLIC_FEATURE_DEADLINE_REMINDERSon, whoever is responsible for a task with a due date is emailed as the deadline approaches and again when it passes — by default a week before, a day before, and when the task is overdue. The email gives the deadline as a local date and time in the recipient's timezone, or the quest's, or UTC. A quest owner who is emailed on behalf of an unassigned task, or of a member with no account, is told who the task is for. Party Quest owners get one email per quest listing up to 50 open tasks that are overdue or due within the widest window (a week by default), with who each task is for, on a day a member's task comes due. The profile's Email preferences section gains a Deadline reminder emails toggle and shows timezone and quiet hours when it is on. Each email's unsubscribe link turns off deadline reminders only. (#572)With
NEXT_PUBLIC_FEATURE_PARTY_INVITESon, a Party Quest owner invites people by email address, including people with no Questify account. The party panel lists pending invitations separately from joined members, marks expired ones, and can send an invitation again or revoke it. It says when each link was issued rather than sent, since the email may not have gone out. The invited person gets an email linking to the new public/party-invites/<token>page. That page shows who invited them and to which quest, offers sign-in or sign-up, and after sign-in has a Join button that opens the quest. Only the account with the invited address can join. If the email cannot be sent, the owner is shown the link to pass on. Signup now keeps anextdestination, including through the confirmation email, and can pre-fill the address, so a new account returns to the invitation instead of going to onboarding. With the flag off, inviting seats an existing account immediately, as before. (#573)Added an admin User Analytics page at
/admin/analytics, linked from the admin sidebar and dashboard. It shows account and activity counts, a daily signup chart for 7, 30 or 90 days, onboarding totals and a per-step table, activation and adoption shares, and the plan mix. Each section reports its own read failure instead of showing zeroes. Each account on/admin/usersnow shows its plan, last activity, last sign-in, onboarding state, quests created and completed, tasks completed, investigations, coach sessions and quest narratives. (#609)
Fixes
An investigation that fails because the model spent its whole response budget thinking now says so. DeepSeek V4 thinks before answering by default, counts that thinking against the budget, and returns it as
reasoning_content, which the worker did not read. The reported DeepSeek failure therefore said the model had stopped at the limit before answering, with no mention of thinking. Anthropic replies that contain only thinking are now reported the same way. The failure messages shown on the task card now tell the person to ask again or choose another persona, and no longer name an environment variable. The failed row keeps the reply's finish reason, token usage and whether it was only thinking indetails.failure, and the worker log explains where the budget is set. The request sent to DeepSeek is unchanged; the 4,800-token budget from 2.19.0 is the fix for the reported failure. (#623)The coach and the quest narrative now log why a DeepSeek or OpenRouter reply came back empty: its finish reason, and whether the model sent only thinking. (#623)
The "No activity for N days" banner now disappears as soon as a task on the quest is started, completed, added or investigated. Before, it stayed until the next 09:00 UTC stall check, up to a day after the person had gone back to the quest. (#605)
Emails now arrive from "Questify" instead of from the sending address's local part, such as "no-reply". The web app and the worker both send a bare
FROM_EMAILasQuestify <address>, and send a value that already has a display name, such asQuestify Support <help@questify.co>, unchanged. A worker email whose first attempt reached Resend before this deploy and is retried after it, within Resend's 24-hour idempotency window, can be refused as a changed request and recorded as failed instead of being sent. (#634)A Party Quest owner whose opted-out member had a task coming due no longer gets an outstanding-work digest for it, and an owner is no longer sent a digest that lists only tasks already finished. (#639)
A Party Quest with 100 expired invitations no longer refuses new ones until the owner revokes old ones by hand. (#639)
Technical changes· 24 entries for database, tooling, CI, tests and docs
Database
Added deadline reminders. A new pg_cron job,
enqueue-deadline-reminders(hourly at :20), callsenqueue_deadline_reminders(), which writes adeadline_reminder_deliveriesrow for every open task on an active quest whose current deadline window opened within the last day. The windows live in the new service-role-onlydeadline_reminder_windowstable, seeded with a week, a day and overdue, and only a task's current window is reminded. The selection query isdeadline_reminder_candidates(). The reminder goes to the task's assignee while their party seat still grants access, and otherwise to the quest owner (task_deadline_recipient()). A partial unique index allows one send per task, window, deadline and recipient, so repeated runs send nothing new, while a moved deadline or a reassigned task is reminded again. When a run reminds someone other than the owner, the owner also gets an outstanding-work digest for that quest, at most once per quest per UTC day, and the owner's own reminders for that quest from the same run are folded into it when it lists them. The digest lists at most 50 tasks, and a reminder for a task beyond that is still sent on its own.claim_deadline_reminder_delivery(),complete_deadline_reminder_delivery()andfail_deadline_reminder_delivery()let the worker send rows. A claim skips a row whose task or quest changed, whose task has moved into a later window, whose recipient changed, or whose address or opt-out no longer allows it. Quiet hours delay a row rather than skip it, and a row expires 23 hours after it was written.notification_preferencesgaineddeadline_reminder_emails(on by default), accepted byupdate_my_notification_preferences()and by the one-clickdeadline_remindersunsubscribe category. Every new function is service-role only. (#572)Party Quest invitations can now be pending.
quest_party_invitationsgainedinvitee_email,token_hash,expires_at,last_sent_at,send_count,revoked_atandrevoked_by_id, and its status now defaults topending. Four new functions do all the writing.create_party_invitation()lets a quest owner invite an address to a quest that has not been deleted, or send an existing pending invitation again, and returns a single-use token once. Only the token's SHA-256 digest is stored, and each token expires after 14 days.revoke_party_invitation()withdraws a pending invitation and keeps its row.get_party_invitation_preview()can be called signed out and describes a token.accept_party_invitation()creates the member seat only when the token is live and the caller's confirmed email matches the invited address. Issuing is rate limited: 50 tokens per inviter in 24 hours, recorded in the new client-invisiblequest_party_invitation_sendstable, whose rows outlive the invitation they count, a 10-minute wait before re-sending, and 100 open invitations per quest. Owners lost their direct UPDATE and DELETE policies on invitations. Their INSERT policy now admits only the legacy directly-added audit row. An invitee can read their own pending row by confirmed email, through the newcurrent_user_confirmed_email(). Existingpendingrows, which had no token and could never be accepted, are markedrevoked. (#573)Added admin-only user analytics functions.
admin_user_analytics_summary()returns headline counts: accounts, new accounts over 7 and 30 days, accounts active in the last day, 7 days and 30 days, accounts that never signed in, onboarding eligibility, starts and finishes, and how many accounts have ever created a quest, completed a task or quest, used AI, played a shared Party Quest or joined a guild.admin_user_signups()returns new accounts per UTC day over 7 to 90 days.admin_onboarding_funnel()returns completed, skipped and stopped counts for each wizard step.admin_plan_mix()returns accounts by plan tier and who set it.admin_user_activity()returns plan, last sign-in, last activity, onboarding state, and quest, task, investigation and coach counts for up to 100 given accounts. Each issecurity definer, refuses callersis_admin()rejects, and is revoked fromanon. Their three helpers are not callable by clients. A new trigger,task_investigations_stamp_initiator, now records the signed-in caller as an investigation'sinitiated_by_idand stops signed-in updates from changing it. Before this, a party member could name another account. An investigation is credited to the account its first charge debited, then to a recorded initiator who owns the quest or holds a seat on it, and otherwise to the quest owner. "Used AI" also counts quest narratives, which leave no ledger row on a free model. Last activity is the later of the last sign-in and the newest session refresh. The public quest catalog's system account is excluded. Accounts backfilled before the onboarding wizard existed are left out of every onboarding figure. (#609)A stall notification now ends as soon as its quest sees activity, instead of at the next daily
check_stalled_quests()run. New statement-level triggers ontasks(insert and update) andtask_investigations(insert) delete the quest's notification and log the sameresolvedevent, with reasonnew_activity_detected, that the daily run writes. Activity is defined as the run defines it: a task'supdated_ator an investigation'screated_atinside the notification's threshold. A trigger onquestsdoes the same, with reasonquest_no_longer_active, when a quest leavesactive. The newstall_quest_has_recent_activity()answers whether the next run would remove a quest's notification, and every reader of a notification now uses it.get_active_stall_notifications()also requires the quest to still be active.enqueue_stall_email_digests()leaves such a notification out of the digest, andclaim_stall_email_batch()skips a digest in which every stall has seen activity. This covers a task write that commits while a run is in progress, which leaves a notification in place until the next day's run.stall_quest_has_recent_activity()runs as the caller, is callable by signed-in users but not anonymously, and was added to the generated types. The three trigger functions are not callable by clients. (#605)Three fixes from the release review, in
20260923120000_release_2200_review_fixes.sql.enqueue_deadline_reminders()no longer writes an owner digest when the only reminder for someone else's task was written asskippedbecause they opted out.claim_deadline_reminder_delivery()now skips a digest asnothing_outstandingonce none of the tasks it lists is still open and due within the widest window, even while other tasks on the quest are open.create_party_invitation()no longer counts expired pending invitations towards the 100-open-invitations cap. No signature or grant changed. (#639)
Tooling & Configuration
Added the
NEXT_PUBLIC_FEATURE_DEADLINE_REMINDERSflag (off by default) to.env.example, the web and worker env schemas, the web Dockerfile,docker-compose.ymland the Playwright web server. The worker has a new deadline reminder loop that sends only when that flag andRESEND_API_KEY,FROM_EMAILandQUESTIFY_SITE_URLare all set. When the flag is on but a value is missing, the worker logs an error at startup naming the missing values.DEADLINE_REMINDER_POLL_INTERVAL_MS(default one minute) sets how often the loop checks for rows to send. (#572)Added the
NEXT_PUBLIC_FEATURE_PARTY_INVITESflag (off by default) to.env.example, the web env schema,turbo.json, the web Dockerfile,docker-compose.ymland the Playwright web server.robots.txtnow disallows/party-invites/. (#573)The dev TUI's From address field now accepts
Name <address>as well as a bare address, and says that a bare address is sent asQuestify <address>..env.exampleanddocs/ops/deployments.mdsay the same. (#634)
CI/CD
The worker deploy now copies
NEXT_PUBLIC_FEATURE_DEADLINE_REMINDERSfrom Vercel production as well, passes it withflyctl deploy --env, and refuses to deploy if a Fly secret of that name would override it. The migrations-only pgTAP step insupabase.ymlnow runsdeadline_reminders.sql. (#572)turbo.jsonnow passesNEXT_PUBLIC_FEATURE_QUEST_RECURRENCE,NEXT_PUBLIC_FEATURE_GUILDS,NEXT_PUBLIC_FEATURE_ATTACHMENTS,NEXT_PUBLIC_FEATURE_STALL_DETECTION,NEXT_PUBLIC_FEATURE_EVENT_REMINDERSandNEXT_PUBLIC_FEATURE_DEADLINE_REMINDERSthrough totest:e2e. Turbo's strict env mode had stripped them, so the Playwright web server always ran with those flags off whatever CI set. (#639)
Tests
Added
supabase/tests/deadline_reminders.sql(66 assertions). It covers the schedule, the privileges, the window configuration, the selection query, recipient fallback, the one-send-per-window dedupe, the owner digest and its 50-task cap, every claim skip reason, quiet-hour deferral, expiry, backoff and the new preference.rls.sqlgained 10 assertions for the new tables, functions and preference column. The worker has renderer, delivery and loop tests, and the web app has tests for the new category and flag in the preference form, its parser and action, the unsubscribe page and route, and the Playwright notification preference spec.worker-deploy.test.mjschecks the third worker email flag. (#572)Added
supabase/tests/party_invitations.sql(62 assertions). It covers privileges, issuing and re-sending, direct-write and direct-delete refusal, a ledger that survives deletion, the preview, who can read an invitation, address-bound and single-use acceptance, revoking, expiry, both rate limits, and invitations to and from deleted quests. The web app has tests for the invitation queries and email, the three new Server Actions, the party panel's pending list, the landing page and its join form, and signup'snexthandling. The newe2e/party-invitations.spec.tscovers the landing page in both flag states and the robots rule, and in live runs the owner's invite-and-revoke flow and an invitee joining from the link. (#573)The worker has tests for DeepSeek
reasoning_content, Anthropic replies that contain only thinking, failure messages that name no environment variables, the operator log line, anddetails.failureon the failed row. The web app has tests for the empty-reply log lines. (#623)Added
supabase/tests/admin_user_analytics.sql(46 assertions). It covers refusing anonymous and non-admin callers, excluding the system account, activity from a session refresh, the pre-wizard onboarding backfill, crediting a party member's task to that member, the investigation initiator stamp, crediting investigations by payer or a verified initiator, zero-cost narratives, signup window clamping and UTC days, and the 100-account cap.rls.sqlgained 2 privilege assertions. Its account-deletion test now clears the fixture investigation's initiator first, because Postgres re-checks foreign keys on a row updated in the same transaction that inserted it. The web app has tests for the analytics readers and formatting, the analytics page and the users page's per-account figures. The new live-onlye2e/user-analytics.spec.tschecks the page, its signup windows, the users list and non-admin refusal. (#609)Added
supabase/tests/stall_resolution.sql(25 assertions). It covers resolution by starting, adding and investigating a task and by completing the quest, theresolvedevents and their reasons, edits that are not activity, activity older than the threshold, and agreement with the next daily run. It also checks that a notification written by a run that raced a task write is hidden from the banner, left out of the email digest, and removed by the next run, that a queued digest is skipped once all its quests have seen activity, and the new functions' privileges. (#605)Added
formatSender()tests in both apps, and Resend request tests that pin the named sender in the web app and the worker. A dev TUI test fails if either app's bare-address pattern stops matching the TUI's own From address check, so an address the TUI rejects, such ashello@questify, is never renamed. (#634)deadline_reminders.sqlcovers an opted-out assignee's task not producing a digest, and a digest whose listed tasks are done being skipped while unlisted tasks stay open.party_invitations.sqlcovers an expired invitation freeing a place under the cap. A dev TUI test fails whenplaywright.config.tsreads a variable that turbo'stest:e2etask does not pass through. (#639)
Documentation
docs/ops/scheduled-jobs.mddescribes the deadline reminder job, how to change its windows, and how to check it and stop it in production.docs/notifications.mdanddocs/ops/deployments.mdlist the new category and flag. (#572)docs/notifications.mddescribes Party Quest invitations: why their email bypasses the preference path, what each database function decides, and the landing page.docs/ops/deployments.mdnames the new flag. (#573)docs/ai-investigations.mdexplains the #623 failure, where an empty answer's details are recorded, and that DeepSeek V4 counts its thinking againstmax_tokens. (#623)Added
docs/admin-user-analytics.md, which defines every user analytics metric and states what it cannot show. (#609)docs/ops/scheduled-jobs.mdsays that the daily run detects stalls, and that activity on the quest ends a stall at once. (#605)docs/notifications.mdhas a new Sender name and logo section: how the From header is built, where the Supabase Auth sender name is set, and the DMARC, logo, certificate and DNS steps Gmail needs before it shows a logo beside the sender. (#634)AGENTS.mdanddocs/notifications.mdsay that aNEXT_PUBLIC_FEATURE_*flag is not enforced by the database, and why the invitation functions stay callable with the flag off.docs/ops/scheduled-jobs.mdanddocs/notifications.mddescribe the review fixes above. (#639)