Skip to main content

Group Check Participants

Web and Contacts require every check participant to belong to the post’s workspace. New check participants must also be current members of the post’s group. Staff can edit or clear an existing check after a student moves to another group in the same workspace; the exception applies only to an existing check on that same post. It does not admit new nonmembers or users from another workspace. Guest status alone neither grants nor removes eligibility. These rules apply to create, update and clear operations, including single-user and batch requests. A batch is validated in full before changes are saved. If participant validation is unavailable, retry after the service recovers. Existing post permissions, approval rules and email delivery settings still apply. Regression coverage: packages/users-core/tests/routes/group-check-membership.test.ts exercises both active handler families with synthetic participants.

Post Email Delivery Recovery

The post email queue now treats provider-accepted delivery as durable once it is written to email_audit. The sent_emails table is still the primary history surface for the product UI, but queue retries must not resend a post email just because the follow-up sent_emails insert or user_group_post_checks.email_id link failed.

Recovery Rules

  • email_audit is created before a send and updated to sent immediately after provider success.
  • Batch processing checks both sent_emails and matching email_audit entries for the same post-recipient pair before sending.
  • If an audit record proves the email was already sent, the worker marks the queue row as sent and backfills sent_emails opportunistically instead of resending.
  • If provider delivery succeeds but local sent_emails persistence fails, the queue row still transitions to sent with a warning in last_error so the system does not retry the message.

Claiming Strategy

  • The queue worker should only claim rows that it can start in the current processing slice.
  • Avoid front-claiming the whole sendLimit; stranded processing rows interact badly with stale cleanup and can create duplicate delivery attempts after requeue.
  • Rejected-recipient cleanup must remain a set-wise private RPC. Scanning every rejected check and updating recipient pairs one at a time can consume the cron’s runtime before approved messages are claimed.

Operations Dashboard

  • The infrastructure Post Email Queue page shows approval/delivery backlog health, age buckets, throughput, sanitized failure categories, workspace breakdowns, and recent batch summaries.
  • Operators should use the page’s Run Now action instead of calling the cron endpoint from the browser. The app route keeps CRON_SECRET server-side and invokes /api/cron/process-post-email-queue with diagnostic output.
  • The cron defaults are tuned for production backlogs: limit=500 and sendLimit=200. Query parameters may still lower those values for focused recovery runs.
  • When a recipient is approved but still shown as queued in the Posts dashboard, inspect the selected-recipient delivery diagnostics before assuming approval failed. Queue age over 1 hour is a warning; queue age over 24 hours should be treated as an incident.

Attendance Exports

The workspace attendance page now supports exporting attendance for everyone across a selected date range.

Behavior

  • The export is inclusive: records on both the start date and end date are included.
  • Exports are workspace-wide and include all recorded attendance rows across groups in the selected period.
  • The UI builds the file client-side, but the data source remains the authenticated internal API route at /api/v1/workspaces/:wsId/users/attendance/export.

Implementation Notes

  • Keep range filtering on the persisted user_group_attendance.date string values with gte(startDate) and lte(endDate) to avoid timezone drift.
  • Attendance exports must bind both workspace_user_groups.ws_id and joined workspace_users.ws_id to the normalized route workspace before returning user names or private email fields.
  • Page the API response for large exports instead of assuming a single Supabase page will return every matching row.
  • Excel and CSV generation should stay in the client so the page can reuse localized column labels and progress feedback.

Workspace Member Profiles

Workspace members can have workspace-specific display names, similar to server profiles.

Behavior

  • Member profile display names are stored on workspace_users.display_name, not on the global users.display_name.
  • The workspace members settings page can edit display names for joined members and pending invites.
  • Pending email invites may create a workspace_users row before the invited platform user joins.
  • When the invited user later joins, workspace_user_linked_users should reuse exactly one unlinked workspace_users row with a matching email in the same workspace.
  • If multiple matching workspace profiles exist for an email, runtime linking must not guess. Admin edit APIs should report the ambiguity so duplicate profiles can be resolved explicitly.
  • The users database page includes a platform-link repair action for administrators with user-update and private-info permissions. It links only matching platform users who are already workspace members; it never creates workspace membership or merges duplicate workspace profiles.

Implementation Notes

  • Keep members-page writes narrow: update the workspace profile display name only.
  • Always verify manage_workspace_members before writing member profiles from the members settings UI.
  • Enhanced member listing should display the workspace profile name when available and fall back to the platform user display name.
  • Auto-linking runs on workspace_members insert. If a virtual profile is created or its email is corrected after membership already exists, use the database-page repair action to reconcile the link.
  • Repair is conservative: a link is inserted only when one unlinked workspace profile and one existing member platform account share the same normalized email. Missing emails, no member match, duplicate workspace profiles, duplicate platform matches, and platform accounts already linked elsewhere are reported as skipped cases.

Workspace invitations

An invitation admits its recipient to the workspace that issued it, and only with the membership type the invitation grants.
  • Scope: workspace membership created from a direct (user) invite or an email invite, through the web accept-invite route or a direct Data API insert.
  • Matching: an email invite is redeemed only by the account whose confirmed Supabase Auth email equals the invited email. The editable profile email in user_private_details and an unconfirmed Auth email never redeem an invite.
  • Type: the joined membership type comes from the invite. A client cannot request a higher type; when both a direct and an email invite exist, the direct invite wins.
  • Creating email invites requires workspace membership plus manage_workspace_members in that workspace (and manage_workspace_roles with a role from the same workspace when a role is attached). Nobody can invite their own email, and seat-based workspaces reject invites without a free seat.
  • Exception: the creator of a workspace with no members can add themselves (workspace bootstrap). Invite links (/invite/[code]) and guest self-join keep their own server-side checks.
  • Regression coverage: apps/database/supabase/tests/workspace-invite-scope.sql and apps/web/src/__tests__/workspace-accept-invite-route.test.ts.

Workspace user read permissions

Session-based user lists and web user detail reads require view_users_public_info or view_users_private_info. The default is denial when neither permission is granted, including for guests without an effective user-read permission. Public-only readers receive no email, phone, birthday, gender, ethnicity, guardian, national ID, address, or notes. Private-only readers receive no public profile fields. Attendance counts additionally require check_user_attendance. Web and Contacts share the field projection; existing workspace API-key integrations retain their separate workspace-key authorization. Per-user attendance requires check_user_attendance; the user, selected classes, and returned attendance must belong to the requested workspace. Per-member vitals require view_user_groups_scores and workspace ownership of both the user and class. Authorized responses retain their existing shapes. Contacts’ editor-specific user lookup retains its separate update_users gate and limited form projection. Mobile CRM continues to use the permission-filtered users/database read. Regression coverage: packages/users-core/tests/routes/workspace-read-authorization.test.ts covers permission denial, field projection, resource scoping, and authorized responses.

Attendance Manager Visibility And Counting

In Contacts workspace settings, open Attendance → Display & Totals, turn off Show Managers in Attendance, and save. The setting is shared by every group attendance-taking view. Both attendance settings default on for workspaces without an explicit value.
  • ATTENDANCE_SHOW_MANAGERS=false hides members whose role in that group is TEACHER (the group manager/teacher role) from attendance-taking rosters and their on-screen totals. It applies to the Contacts attendance page, group detail attendance, the shared web attendance client, and the TanStack replacement. It does not remove a manager from the group, alter saved attendance, or erase historical records and exports.
  • The setting is read for the first server render of group detail attendance. Client-only rosters wait for the setting before showing managers, so a hidden manager does not briefly appear while the setting loads or refreshes. The setting is scoped to the workspace, not an individual browser or group.
  • ATTENDANCE_COUNT_MANAGERS controls the separate group-list attendance totals. When enabled, group managers count toward those totals. When disabled, the totals subtract group managers from the group member count.
When a product change touches attendance, check the settings UI, every roster entry point, summary counts, exports, and the live Next.js API plus the TanStack/Rust migration path. Record any intentional difference here before release so a later app port cannot reintroduce a resolved behavior.

User Group Activity Audit Logs

User group activity is exposed from the dashboard at /users/groups?tab=audit-log and on each group detail page. The feed is normalized from audit.record_version through private service-role RPCs so UI code receives one event shape with action, resource type, group, affected user, actor, timestamp, and before/after snapshots. The Contacts audit view must show a distinct error when the feed query fails. An unavailable feed is not evidence that no changes occurred. Investigate the server error and the private.list_user_group_activity_logs RPC before using an empty view to reconcile a student’s history. Existing membership rows show their current join timestamp; an audit event is needed to establish a prior removal and rejoin. Neither a receipt for a different group nor membership history alone proves that tuition was transferred or credited to another class. Keep the original invoice and any explicit adjustment or transfer record when reconciling debt. The feed reads a date range of audit.record_version before resolving groups and actors. Keep its partial date index aligned with the current public and private table names whenever a tracked resource moves schemas. A PostgreSQL 57014 timeout means the feed is unavailable, not empty; check the exact query plan and index validity before widening the statement timeout. The concurrent activity index migration 20260929130000_user_group_activity_current_schema_index.sql restored schema alignment, but did not by itself resolve the reported timeout. An OR-based workspace/group predicate and then an unscoped workspace-wide orphan scan also timed out after reaching production. The feed now builds indexed candidates for direct group events, workspace metadata, child records, workspace users, and metric categories. Historical group IDs retain member and attendance events after a group is deleted. The workspace-wide view retains feedback and child events that resolve through a student or category after their parent is deleted. Keep each branch’s table predicate aligned with its partial index when adding an audited resource. The unfiltered workspace listing counts and sorts narrow candidate IDs, then calculates snapshots, labels, and actors for the selected page. Searches and group-scoped requests still use the full feed so filters see enriched fields. The listing RPC owns ordering; do not sort every wide feed row inside user_group_activity_feed as well. Compare candidate IDs and counts with the full feed, benchmark a representative 30-day range locally, and verify live response time after release. The three-argument feed delegates to the same implementation.

Local performance verification

The fast-page migrations preserve the existing RPC signature and service-role-only access. Only the default unfiltered workspace request uses narrow IDs; search, resource/action filters, group scope, and ranges whose snapshots reference multiple fallback actor links retain the enriched path. Fallback actors may belong to another workspace; the multiplicity guard checks eligible historical snapshot references rather than current workspace ownership. It conservatively checks all potential actor fields, including unchanged update fields. Narrow eligibility checks supported row operations without calculating snapshot diffs. Action labels, changed fields, names, and snapshots are still calculated for the selected page. The 2026-09-30 same-data benchmark used isolated local PostgreSQL, synthetic feedback events across 28 days, equally distributed inserts/updates/deletes, roughly 1.9 KiB content snapshots, 4 MB work_mem, and a 20-row page at offset 20. Both implementations read the same rows in one session. Each had one warm-up and five measured runs; the p95 values below are interpolated from these five samples, not production SLA estimates. An additional 48k fixture with an unrelated virtual actor in another workspace linked to two platform users measured baseline median/sample p95 5,651/5,842 ms versus fast 2,379/2,701 ms. The scoped ambiguity check adds work but retains early pagination when those actor IDs are absent from the audit snapshots. Use the harness option unrelated_actor_links=true to reproduce this case. Full-row multiset equality, including counts and snapshots, passed at every size. The regression suite also covers all 18 resources and three operations, tied sorting, offsets and normalized limits, date boundaries, filters, workspace isolation, same-workspace and cross-workspace fallback actor multiplicity, service-role execution, and actual denied anon/authenticated calls. Restrictive MFA policies must not be counted as permissive access grants in policy assertions; no policy or grant is changed by this optimization. The reproducible rollback-only harness and raw JSON samples are in apps/database/supabase/benchmarks/user-group-activity-fast-page.sql and user-group-activity-fast-page-results.json. Use a disposable database named audit_benchmark_* or audit_takeover_*; the harness rejects other names. Install the previous listing from migration 20260929153000_scope_user_group_activity_before_enrichment.sql under the test-only name private.list_user_group_activity_logs_baseline, then apply the three fast-page migrations. Run through the resource queue with psql variables size=48000 and baseline_rpc=list_user_group_activity_logs_baseline. Benchmark rows roll back. Never run this synthetic fixture against shared or production data. Candidate resolution extracts the snapshot anchor fields together before parent joins. Repeated ->> calls on large TOASTed JSON can fetch the same snapshot for each join even when the required partial indexes are valid. Preserve try_parse_uuid, current/old fallback, and workspace precedence when changing anchor extraction; malformed non-object snapshots retain null-field behavior. Use user-group-activity-anchor-extraction.sql to compare the deployed candidate helper with this follow-up on the same synthetic mixed-operation history. It includes representative parent cardinality and both inline (toast_snapshots=false) and TOASTed (toast_snapshots=true) snapshots. Keep production timeout settings unchanged while diagnosing; a warm fixed-window success does not prove the default rolling 30-day request is stable. The anchor-extraction benchmark adds 72,000 mixed-operation activity snapshots and 570,000 unrelated older audit rows, with representative user/group/post/report cardinality. Alternating implementations on the same data (one warm-up and five measured runs) produced: Candidate and full-page multiset parity passed in both scenarios. Raw samples and exact source hashes are in user-group-activity-anchor-extraction-results.json. These local measurements do not establish production latency or an SLA. Confirm the default rolling 30-day UI after release; retain an unavailable state for a timeout rather than displaying an empty history. Broad tests on the schema-only local fixture reproduced 43 failing suites on the original HEAD. Two stale audit policy assertions are repaired here; the remaining 41 unrelated failures have identical baseline failure records. This fixture omits scheduler setup and migration-seeded reference catalogs. A separate fresh Supabase bootstrap applies the migrations and passes the 24-case parity suite; its full test run has 35 failing suites before the change and 33 afterwards, with no new failures and identical existing failure summaries. Type generation from that fresh schema and a non-emitting type check pass. The full suite is therefore still blocked by unrelated failures. Require green exact-commit CI, plus a read-only live 30-day workspace/group/filter check after production rollout before declaring the original timeout fixed.

Tracked Resources

  • Group metadata and status: workspace_user_groups.
  • Membership and manager role changes: workspace_user_groups_users.
  • Group tags and default included groups: workspace_user_group_tag_groups, workspace_default_included_user_groups.
  • Posts, post logs, and post checks: user_group_posts, user_group_post_logs, user_group_post_checks.
  • Attendance, linked products, metrics, categories, category links, and student metric values: user_group_attendance, user_group_linked_products, user_group_metrics, user_group_metric_categories, user_group_metric_category_links, user_indicators.
  • Reports and feedback: external_user_monthly_reports, external_user_monthly_report_logs, user_feedbacks.
  • Course content: workspace_course_modules, workspace_course_module_groups.

Actor Attribution

  • Core group, member, metric, student metric value, and attendance mutations should use the private actor-aware RPCs so service-role writes set audit.override_auth_uid before changing rows.
  • Other tracked tables still appear when raw audit rows exist. If a route writes with a service-role client and the table does not preserve actor intent in columns such as creator_id or updated_by, add a focused actor-aware private RPC or route-level activity insert before expanding the UI around that resource.
  • Bulk reorder operations are normalized as reordered events when the persisted sort_key changes.

Workspace Validation

  • Before any admin write, normalize the route wsId and verify the target group belongs to that workspace.
  • Member writes must also verify every affected workspace_users.id belongs to the same normalized workspace before inserting, updating, or deleting membership rows.
  • Attendance writes must additionally verify every affected user is already a member of the target workspace_user_groups_users row before inserting or deleting user_group_attendance.
  • Child resource writes should validate their parent group or parent module group before the admin mutation. Do not rely on a raw group_id from the URL without checking ownership.

Topic Announcements

Topic Announcements live under Users and are gated by the workspace secret ENABLE_TOPIC_ANNOUNCEMENTS=true.

Behavior

  • Contacts are workspace-scoped and may be raw email contacts or linked to an existing workspace virtual user profile. The linker UI searches workspace users with avatar and email affordances.
  • Announcements may optionally reference a real workspace_user_groups row through group_id for scheduling and reporting context.
  • A contact cannot receive announcements until its email is verified through the Tuturuuu internal SES verification flow or through a linked Tuturuuu platform account with a confirmed matching email and membership in the same workspace. Platform-wide confirmed accounts must not auto-confirm a contact for another workspace.
  • Unverified recipients block a send; there is no bypass. A staff member cannot attest an address instead of the recipient clicking the link: in a multi-tenant open-source product that would let any sender in an enabled workspace mail arbitrary addresses from our domain. An interactive send (single, create-and-send, or import-and-send) to an unverified recipient sends nothing, leaves the announcement unchanged, and names the recipients with a Send verification action. Only the scheduled queue records the skip on the row (status = skipped, last_error = EMAIL_NOT_VERIFIED: ...), because nobody is watching it. The contacts page offers Send verification for every unverified contact at once; verification email stays rate limited per sender, so a large batch stops at the limit and reports how many are left. Coverage: send-admission.test.ts, topic-announcements-send.test.ts, topic-announcements-verification-requests.test.ts.
  • Announcements can include image and PDF attachments. The composer and API enforce the shared email limits: up to 5 files and 10 MB total per announcement.
  • Verification emails are sent by the root Tuturuuu sender with normal protected delivery checks, not the rate-limit-bypassing system path.
  • Verification links resolve to the public platform origin by default (https://tuturuuu.com) so listener hosts such as 0.0.0.0:7803 never leak into recipient inboxes. Set TOPIC_ANNOUNCEMENT_VERIFICATION_ORIGIN only when an explicit alternate public origin is required.
  • Verification sends enforce a short pending-link cooldown before creating another token, and email delivery applies workspace, user, IP, and recipient limits. The recipient limit is global across workspaces, so one workspace or many workspaces cannot repeatedly send verification links to the same inbox.
  • Actual announcements are sent by the enabled workspace sender and are written to email_audit.
  • The composer and table send flow preview the same server-rendered HTML and text payload that delivery uses. Operators should review that rendered preview before sending or scheduling announcements.
  • The first import preset supports foreign-teacher schedule rows with day/date, class, room, time, teacher/contact, email, place, and topic fields. The import UI now uses an editable Excel-like table as the review surface; uploaded spreadsheets, CSV, and direct paste all load into that table so operators can correct every email field before creating drafts or sending valid rows in bulk.
  • Place picker. The composer’s Place field offers the workspace’s campus labels (the tutoring campus list plus labels mapped to classes) and stores the chosen label as plain text in place; room stays a separate field. A manual or template place that is not a campus still works and is saved unchanged. The labels come only from the current workspace’s tutoring policy through GET /api/v1/workspaces/{wsId}/topic-announcements/campus-options, which requires the same supervisor access as creating a topic (manage_users) and returns only the labels, not the policy. With no campuses configured, only the free-text place is shown. Coverage: campus-options/route.test.ts, announcement-place-field.test.tsx.
  • Workspace-shared announcement templates store reusable title, topic starter text, class metadata, optional default group, and default recipient ids. Operators apply a template in the composer, then edit the topic field before sending.
  • Operators can fork an existing sent or draft announcement into a new draft. The fork copies title, topic, class context, recipients, and attachment references, but never copies sent, queued, scheduled, or email audit state.
  • Scheduled send sets status = queued and scheduled_send_at using the workspace’s fixed IANA timezone from calendar settings. Scheduling is blocked while the workspace timezone is still auto or unset; use the same calendar timezone gate as the main calendar product.
  • The process-topic-announcement-queue cron job (every 15 minutes) loads due queued rows and reuses the same sendTopicAnnouncement path as manual send. Failed sends keep the existing failed / last_error handling without infinite retries.
  • Announcement list APIs enrich recipient rows with the same verificationStatus contract as the contacts list so the table does not treat missing status as unverified.

Implementation Notes

  • Client UI must call @tuturuuu/internal-api Topic Announcements helpers; do not add direct client-side fetches.
  • Email preview must go through the Topic Announcements preview API and shared renderer instead of a client-only template so previews cannot drift from actual delivery.
  • Bulk create/send must keep using the import API plus send-bulk helper from @tuturuuu/internal-api; do not bypass the shared renderer, recipient verification, or delivery gates for spreadsheet rows.
  • Delivery APIs must check the workspace secret, manage_users, send_user_group_post_emails, recipient verification, and workspace ownership before sending.
  • If a contact email changes, previous verification records for the old email must no longer satisfy delivery checks.
  • Linked-account auto-confirm checks must join through workspace_members for the contact workspace. Do not treat a confirmed Tuturuuu account as sufficient unless that platform user belongs to the same workspace as the linked virtual user.
  • Attachment uploads must prepare signed upload URLs through the central workspace storage route, then upload bytes directly to the active storage provider. Do not stream attachment bytes through Topic Announcement-specific Next.js routes.
  • Attachments live in the service-role-only topic-announcements/ storage root (migration 20261026192000). Drive permissions (view_drive, manage_drive) never grant direct Storage API or Drive-route access to them; only the feature gate plus manage_users can sign uploads. Coverage: apps/database/supabase/tests/workspace-storage-least-privilege-rls.sql and the storage upload-url route tests.
  • User group and course storage uploads are quota-sensitive and must use the server-mediated uploadWorkspaceUserGroupStorageFile / group storage multipart route. Do not mint signed upload URLs from this route because the app server cannot verify the final object size after a direct provider upload.
  • Attachment rows keep workspace, announcement, storage path, content type, and size metadata. Email delivery downloads the stored files and switches to raw MIME only when attachments exist; regular no-attachment messages should keep the standard SES SendEmailCommand path.

Monthly GVNN schedule

Supervisors enter each month’s foreign-teacher (GVNN) schedule directly in the system; there is no spreadsheet import. Scope: the contacts app Topic announcements > GVNN schedule tab and GET/PUT /api/v1/workspaces/{wsId}/topic-announcements/schedule plus POST .../schedule/clone. Gate: supervisor scope (manage_users) and only with ENABLE_TOPIC_TEACHER_ASSIGNMENTS=true; with it off the tab is hidden and every schedule route returns 404, so live workspaces keep the pre-feature module unchanged.
  • Session. A session is a topic-less draft announcement with source_type = 'schedule': class (group_id and its name), date, start and end time, room, campus, the GVCN as teacher_workspace_user_id, and the GVNN as its one recipient contact. It also stores the 24-hour send_due_at deadline described below.
  • Duration. A GVNN period defaults to 30 minutes (end = start + 30); the supervisor can change it per session (5 to 240 minutes, ending before midnight). The duration is stored per session through its end time.
  • GVCN (homeroom teacher). Assigned by the server, never by the client: the class’s TEACHER member in workspace_user_groups_users. With several teachers the earliest assigned (membership created_at, then id) is used; every teacher of the class still has topic access through the group rule. A class with no teacher is saved without a GVCN and shown as “No homeroom teacher”. Editing a session within the same class keeps its assigned teacher; moving it to another class assigns that class’s GVCN.
  • GVNN. Data only, no role: any active contact tagged GVNN (case-insensitive) is offered in the picker. Sessions can be saved without a GVNN and show “Needs GVNN”; the editor shows a count badge, a filter and a per-class list with jump-to-session for the month. Classes later marked as not needing a GVNN are excluded from that list.
  • Clone from the previous month. Each session maps to the same nth weekday of the target month (the 2nd Tuesday stays the 2nd Tuesday), which keeps a class’s two or three monthly GVNN sessions in the same weeks; filling every matching weekday would give four or five. A 5th weekday the target month lacks is shown as “not placed” and skipped, never moved. There is no holiday calendar. The clone copies class, time, duration, room, campus and GVNN (if the contact is still active), re-resolves the GVCN, and never copies topics or attachments. The supervisor sees a preview before committing.
  • Idempotency. A class cannot have two sessions with the same date and start time. Re-cloning or committing a stale preview skips slots that already hold a session. A slot whose session was removed this month is skipped too (“removed earlier”) unless the supervisor chooses to re-add removed sessions. Retrying a save never duplicates: a new row whose slot already holds an identical session is reported as already saved, while a different session in that slot is a per-row conflict; removing an already removed session succeeds. Two clones committed at exactly the same moment are not protected by a database unique key.
  • Edit and remove. Start time, minutes, class, GVNN, room and campus are edited inline; “Move to date…” in a row’s menu moves a session within the month and focuses it at its new date. Saves are validated as a whole before anything is written, and each write re-checks that its session is still a draft, failed or skipped one: a session that was queued or started sending in the meantime is left unchanged and the save returns 409 with the sessions that did save, after which the editor reloads the month.
  • Changing the GVNN. A GVNN change is written before the other fields, and only while the session is still editable. A send now claims a schedule session (editable to processing) before it reads the recipients, so the editor and a send cannot both win:
    • claimed before the save: nothing changes (locked);
    • claimed during the GVNN swap: the other fields stay as they were and the save reports partially_applied with the GVNN as the only change; the email may have used the previous GVNN, and nothing is restored because the send may already have read the recipients;
    • the swap removes the old recipient before adding the new one, so a send sees the old GVNN or none (and refuses with NO_RECIPIENTS), never both;
    • if the swap itself fails, the previous GVNN is put back while the session is still editable. A fully atomic swap would need a database function (a migration).
  • Sending schedule sessions. “Send now” and bulk send claim a schedule session first; a session already queued or sending answers SEND_CONFLICT and a sent one ALREADY_SENT (a resend may claim it). If the send stops early the claim returns to the previous status. A queued schedule session that the queue finds without a recipient is marked failed with NO_RECIPIENTS (editable, so a GVNN can be assigned) instead of being retried every 15 minutes. The claim matches the exact status it read and is released to that status. If a Send now never finishes (the function was stopped, or the release failed), the queue marks the session failed with PROCESSING_TIMEOUT 15–30 minutes later (the queue runs every 15 minutes and reclaims claims older than 15): editable, and never sent automatically. This sweep runs first in every queue run, so it still happens when the rest of the run fails. Other announcements send exactly as before.
  • Save & notify teachers. Saving the schedule or committing a clone can notify each GVCN of the sessions that request assigned to them: new sessions, or a class change that gives a session another teacher. The checkbox (“Notify teachers (N)”) appears next to Save and in the clone dialog when N > 0 and is on by default. It reuses the topic_assigned in-app notification and notify-teachers sender: one notification per teacher, nothing emailed. Teachers without a linked account are skipped and reported in a toast. Unchanged, retried or same-teacher saves assign nothing and notify nothing, so no extra state is stored; a notification that fails after the save is reported and not retried. Known limits: two tabs saving the same new session at once can each notify, and a session moved from teacher A to B and back to A notifies A again. When part of a conflicting save went through, its notification outcome is shown too.
  • Removing. Removing a session cancels it (status = 'cancelled') like any announcement, keeping its topic and audit trail. Queued, sending and sent sessions are read-only and labelled as such.
  • Unsaved changes. Changing month, following an in-app link, one step of browser Back, and closing or reloading the tab all ask before discarding edits. Back is guarded by keeping one extra history entry for the page while there are edits, and only when the tab has an earlier page; confirmed navigation removes that entry first, so none is left behind. Jumping back several pages at once from the Back button’s history menu is not intercepted. Problems on a row are shown once it has been edited or a save was attempted.
  • Import. The CSV import is unchanged by this feature; the schedule editor does not use it.
Regression coverage: packages/utils/src/topic-schedule.test.ts (nth-weekday mapping across month lengths, the 5th-weekday edge, week boundaries, no holiday skipping, GVCN choice), apps/web/src/lib/topic-schedule/clone-plan.test.ts (in-run dedupe, removed classes, removed slots), apps/web/src/lib/topic-schedule/schedule-routes.test.ts (GVCN auto-assign, idempotent save and re-clone, cross-workspace denial, locking), apps/web/src/lib/topic-schedule/schedule-safety.test.ts (flag off: no routes and nothing written; edit and delete locks including mid-save conflicts, retried removal, slot conflicts, archived classes and GVNN, same class teacher kept, clone revival of removed sessions), apps/contacts/src/app/[locale]/[wsId]/users/topic-announcements/schedule/schedule-draft.test.ts schedule-month-editor.test.tsx and schedule-unsaved-guard.test.tsx (save payload, validation shown after touch, missing-GVNN reminder, Back and link guards without stray history entries, rows kept in saved order, delivery-state labels), and apps/web/src/lib/topic-schedule/schedule-races.test.ts (GVNN swap racing a send, chunked recipient reads), apps/web/src/lib/topic-schedule/schedule-notify.test.ts (notify on new sessions and teacher changes, none on unchanged or retried saves, other workspace links and sessions ignored, flag off, clone), schedule-notify-toast.test.ts (skipped unlinked teachers toast).

Teacher-assigned topics

Supervisors can create a month of sessions before topics exist, assign each session to the teacher who writes its topic, and track whether topics went out on time. Schema: 20261026193000_topic_announcement_teacher_assignment.sql. Default for every workspace: no teacher, no deadline, existing behavior unchanged.
  • Draft topics. topic may be empty only while status = draft. Every other status still requires a non-blank topic, so nothing without a topic can be queued or sent.
  • Teacher in charge. teacher_workspace_user_id must be a workspace user of the same workspace (trigger-enforced). Deleting that user clears the assignment. A topic_assigned notification type is reserved for the supervisor’s save-and-notify action.
  • 24-hour deadline. On write, the app stores send_due_at = session start (session_date + start_time in the workspace’s fixed calendar timezone) minus 24 hours. Sessions without a date or start time have no deadline.
  • Late is computed on read, never stored. Sent: sent_at is set. Late: sent_at is null and now() > send_due_at. Not sent: otherwise. There is no status column, sweep, or cron for lateness. A resend to a substitute bumps delivery_revision and keeps the first sent_at, so it never changes whether the topic went out on time. Proactive late alerts are out of scope until requested; they would run on Cloudflare under the bounded, stop-fenced rules.
  • Course book and video link. course_book is at most 200 characters. video_url must be an https:// link without whitespace, at most 2000 characters.
  • Email verification is kept. Teacher-assigned topics use the same recipient verification gate as every other topic email. There is no staff-attested bypass: this is a multi-tenant open-source product, and a bypass would let any sender in an enabled workspace mail arbitrary addresses from our domain. Each recipient verifies once (or is linked to a confirmed same-workspace account).

Teacher access and write paths

Rollout gate. Everything in this section is behind the workspace secret ENABLE_TOPIC_TEACHER_ASSIGNMENTS=true, separate from ENABLE_TOPIC_ANNOUNCEMENTS and off by default (it fails closed: every secret row with that name must be true), because workspaces already send topic announcements. With it off, the supervisor create, update, import and send paths behave as before the feature: a topic is required whenever one is written (no topic-less drafts), teacher-only fields (teacherWorkspaceUserId, courseBook, videoUrl) are ignored, no send_due_at is stored, a resend restamps sent_at, the teacher branch of the access check does not exist, and the notify route answers 404. Safety-only denials ship regardless of the flag: teachers cannot create topics or change fields beyond the topic content, resend is supervisor-only, attachments on sent or processing topics are fixed, the list search cannot widen a scope, and a send with a blank topic is refused. In Contacts, the teacher picker, the notify checkbox, the course book and video fields, the topic-less draft allowance, the early composer reset, the “My topics” teacher view and its deadlines appear only with the flag on; with it off the composer sends the pre-feature payload. The sidebar entry keeps its manage_users rule; teachers open “My topics” from the topic_assigned notification. Regression coverage: apps/web/src/legacy-api-routes/v1/workspaces/[wsId]/topic-announcements/supervisor-flow-flag-off.test.ts, the “teacher assignments off” cases in announcement-form.test.tsx, and the flag-off case in send-admission.test.ts.
  • Supervisor scope is unchanged. manage_users reads and writes every topic in the workspace and is still the only scope for import, contacts, templates, removal, and the save-and-notify action. Supervisor sending still requires manage_users plus send_user_group_post_emails.
  • Assignment-scoped teachers. Without manage_users, a member may list and preview a topic with create_user_groups_posts or update_user_groups_posts, and may upload page images or attachments for it and edit it only with update_user_groups_posts (a create-only teacher cannot attach a file, so the upload answers 403 as well; coverage: topic-announcements/attachments/upload/route.test.ts). Every operation also needs the topic to be assigned to them: their linked workspace user is teacher_workspace_user_id, or, only while no teacher is assigned, they are a TEACHER of the topic’s class (group_id). A co-teacher of the class cannot act on a topic assigned to another teacher. The PATCH write repeats the assignment filter, so a reassignment during an edit wins. Teacher sending, scheduling, and cancelling a schedule require send_user_group_post_emails plus the same assignment check. Topics outside the assignment answer 404; a member with no linked workspace user is denied. ENABLE_TOPIC_ANNOUNCEMENTS remains the gate for both scopes.
  • Teacher write limits. Teachers may change only topic, body, courseBook, videoUrl, and attachmentDrafts, and may set status only to draft on a topic that is already a draft (re-saving a scheduled topic as a draft would cancel its send). Any other field (title, class, room, place, day, date, times, source, recipients, assignment) answers 403, so a teacher cannot move their own deadline. They cannot edit sent, processing, or cancelled topics, cannot resend (resend=true is supervisor-only, single and bulk), and cannot create topics: creation is supervisor-only because the creator picks recipients.
  • Attachments are fixed once sent. Nobody can add or remove attachments on a sent or processing topic (409).
  • Blank topics are NULL. Every write path (create, update, teacher edit, import) stores a blank or whitespace-only topic as NULL, never '', so a topic-less draft is always topic is null.
  • Validation before the database. A teacher from another workspace is rejected with 400 instead of relying on the trigger. A blank topic outside a draft returns TOPIC_REQUIRED from update, schedule, and send.
  • Deadline on every write path. Create, update (when the date or start time changes), the import, and the GVNN schedule editor and clone store send_due_at through @tuturuuu/utils/topic-announcement-deadline, using the workspace’s fixed calendar timezone. Without a fixed timezone the deadline stays empty. getTopicAnnouncementDeliveryState derives Sent, Late, and Not sent.
  • Save and notify. POST .../topic-announcements/notify-teachers creates one topic_assigned in-app notification per assigned teacher across the given topics. Teachers whose workspace user is not linked to a platform account are skipped and counted; nothing is emailed.
  • Email content. The topic email lists the course book and video link when present.
  • Contacts UI. Supervisors pick the teacher in charge on the composer’s details step and can leave the topic empty for a draft; “Notify the teacher after saving” (on by default) calls the notify route after the save. Members without manage_users who hold a user group post permission see a scoped “My topics” view at the same route instead of the supervisor workspace, with Sent / Late / Not sent badges, and a form for course book, topic and objectives, page images, and video link. “Save draft” and “Save & send” (shown with send_user_group_post_emails) use the update and send routes above.
  • No duplicate topics or emails on partial failure. The composer resets as soon as the topic is created, so a later send, schedule, or notify failure cannot lead to a second topic; a notify failure has its own message. In the teacher form, saved attachment drafts are cleared right after the save, before any send, and the update route ignores drafts whose storage path is already attached, so a retried “Save & send” never attaches or emails a file twice.
  • Teacher picker. The teacher-in-charge picker searches workspace users on the server (name or email) instead of a fixed first page, so any user in a large workspace can be assigned. The GVNN schedule does not use the picker: it assigns the class’s GVCN automatically.
  • Reassignment notifies. When a supervisor changes teacherWorkspaceUserId through the update route, the new teacher gets a topic_assigned notification (a notify failure does not undo the change).
  • My topics pages past 50 topics and shows deadlines in the workspace’s calendar timezone (the browser zone if none is set).
Regression coverage: apps/database/supabase/tests/topic-announcement-teacher-assignment.sql, packages/utils/src/topic-announcement-deadline.test.ts, packages/users-core/src/routes/topic-announcements/access.test.ts, apps/web/src/legacy-api-routes/v1/workspaces/[wsId]/topic-announcements/teacher-access.test.ts (cross-teacher, cross-workspace, and unassigned-class denial), apps/web/src/legacy-api-routes/v1/workspaces/[wsId]/topic-announcements/teacher-writes.test.ts, the Contacts satellite list in apps/contacts/src/app/api/v1/workspaces/[wsId]/topic-announcements/route.test.ts, the notification labels in apps/web/src/components/notifications/notification-type-messages.test.ts, and the Contacts UI in announcement-form.test.tsx, teacher-topics-view.test.tsx, and teacher-user-picker.test.tsx under apps/contacts/src/app/[locale]/[wsId]/users/topic-announcements/. Rollback: apps/database/rollbacks/20261026193000_topic_announcement_teacher_assignment.rollback.sql, proven by apps/database/verification/topic-announcement-teacher-assignment-rollback-roundtrip.sql.

Teacher deadline notices (schema only)

Migration 20261029050200_topic_teacher_deadline_notices prepares the GVCN deadline notices; no sender is wired yet, so nothing changes for users.
  • Types. topic_due_soon (one reminder before the 24-hour deadline, send_due_at) and topic_late (one notice after it passes unsent).
  • One email, not two. These notices email the teacher directly (sendWorkspaceEmail, with the email audit) at their confirmed account email. should_send_notification answers false for their email channel, so create_notification records only the in-app notification and never queues a digest email for them. Other types are unchanged.
  • At most one of each. private.topic_announcement_teacher_notices holds one receipt per announcement, kind and send_due_at, claimed with insert ... on conflict do nothing before notifying. A rerun, restart or overlapping run never notifies twice, deleting the notification keeps the receipt, and a new send_due_at (the session moved) re-arms both notices. Receipts are service-role only, belong to their announcement’s workspace (composite foreign key on announcement and workspace) and cascade with it.
  • Rollback. apps/database/rollbacks/20261029050200_topic_teacher_deadline_notices.rollback.sql, proved by apps/database/verification/topic-teacher-deadline-notices-rollback-roundtrip.sql.
Regression coverage: apps/database/supabase/tests/topic-teacher-deadline-notices.sql.

Mobile profile image optimization

Mobile avatars use a square crop; choose 512–1024 pixels per side. Banners use web’s 3:1 display aspect, for example 2400 by 800 pixels. The mobile optimizer preserves aspect and never enlarges small images, fitting avatars within 1024 by 1024 and banners within the server’s 2560 by 1440 canvas. Camera/gallery selection uses the native picker with bounded dimensions and quality conversion, including HEIC/HEIF to JPEG on iOS, followed by a native JPEG crop. The shared optimizer runs decoding and encoding off the UI isolate, bakes EXIF orientation, takes the first animation frame, and strips metadata. Opaque photos and screenshots use JPEG quality steps (90, 82, 72, 60), then progressively smaller dimensions until they fit. Transparent input retains PNG when it fits at the current size; otherwise it is flattened onto white for JPEG. The same optimizer handles offline replay and workspace avatars; there is no second codec policy. Workspace avatar authorization and its separate storage upload protocol retain their existing limits. The source allowance is 20 MiB with a separate 20-megapixel decode ceiling, bounding memory use on mobile devices. Input beyond these safety limits is rejected before full pixel decoding, and oversized files before reading their bytes. Upload targets remain 1,000,000 bytes for avatars and 2,000,000 for banners, below the server’s authoritative 2 MiB and 5 MiB caps. Only the exact profile PUT paths with a nonempty token query parameter receive those proxy limits; requests without one retain the generic 512 KiB cap. The handler still validates the ticket. Unrelated API payloads retain their normal limit. Actual streamed bytes, one-use tickets, upload budgets and server-side WebP re-encoding remain authoritative; client sizes do not grant permission. Proxy consumers import the dependency-free @tuturuuu/storage-core/profile-upload-limits module. The server-only budget module re-exports the same limits for existing handler callers. Empty/corrupt images and native decode failures use invalid-image guidance, rather than size guidance. Recovery uses a compact design-system alert and retains fresh selection, dismissal and authorized sanitized diagnostics. Profile image recovery with a fresh selection and dismissal Regression coverage: profile_media_optimizer_test.dart exercises phone JPEG, PNG, tiny and panoramic images, orientation/metadata, transparency, the exact 20 MiB source boundary and rejection above it. profile_native_media_options_test.dart covers picker/crop conversion options (real HEIC selection still needs device acceptance). profile_media_failure_mapping_test.dart and the existing actor, delivery and recovery suites cover errors and ownership. apps/web/src/lib/profile-media-proxy.test.ts exercises the real proxy guard and route exports, including valid ticketed images above the old 512 KiB proxy ceiling and the generic cap for missing or empty tickets.

Mobile profile banners

Mobile profile headers reserve a 3:1 banner area, including an empty placeholder, image loading indicator and image-error fallback. Banner selection uses the native camera/gallery chooser and a locked 3:1 crop, then the shared optimizer. Upload and replacement reuse POST /api/v1/users/me/banner/upload-url, the signed PUT, and POST /api/v1/users/me/banner with action: finalize; removal uses the latter with action: remove. Each mutation retains its operation UUID and reloads the actor’s profile. These controls do not change shell navigation or account access. Permission denial, size rejection, connection failure and invalid/expired upload tickets have scoped English and Vietnamese guidance. Ticket failure asks for a new selection; it does not bypass signature checks. Avatar upload authorization continues to use its existing error mapping. Regression coverage lives in profile_banner_lifecycle_test.dart, profile_banner_states_test.dart, profile_banner_picker_test.dart and profile_media_failure_contract_test.dart.

Browser profile capability uploads

The central avatar and banner upload endpoints expose credential-free CORS for literal registered internal app origins, including Lettin and approved Portless origins and ports, and exact configured external app origins. External matching uses the configured protocol, host and port; it does not query database registration or enabled state. Production HTTPS registrations do not admit HTTP downgrades. Arbitrary host suffixes, localhost ports and forwarded hosts do not grant access. Explicit OPTIONS permits only PUT and Content-Type. It does not verify or consume the upload capability, read a body or access Auth, lifecycle, optimizer or storage services. Responses are not cached and vary on Origin and preflight method/header names. Trusted-origin PUT success and errors expose the exact requesting origin and retain existing Vary values. No credentials, Authorization or private network access grant is added. CORS controls browser response visibility. Upload authority still comes from the short-lived, atomically consumed capability and its actor, kind and scope checks. Untrusted origins receive no CORS grant; native requests without Origin retain existing behavior. Banner issued-state checks, immutable storage and separate finalization/replay rules remain in force. Transport unit regressions live in apps/web/src/lib/profile-media-upload-cors.test.ts and apps/web/src/lib/profile-media-upload-put.test.ts. These source checks do not prove deployed browser preflight, persistence or finalization behavior.

Public platform profiles

tuturuuu.com/u/[username] resolves the current canonical username and renders only the user’s avatar, banner, display name, and biography. The route supports English and Vietnamese and anonymous access. Unknown or invalid usernames have no profile. Public profiles are request-time reads and are not advertised for search indexing. The storage projection and returned object independently allowlist the four fields; email, account IDs, timestamps, workspace membership, and activity are never selected or returned. HTTPS images reject embedded credentials. Other profile information remains private unless the owner explicitly shares it on an owning app surface. Workspace access and saving private identity fields do not imply public-sharing consent. This initial platform page has no additional field-sharing controls. Lettin provides an explicit saved opt-in for its About section; canonical biography is public by default, while workspace data stays private. Public creator names link to the canonical platform profile when a username exists. Regression evidence: apps/web/src/lib/public-user-profile.test.ts checks the query and response allowlists, invalid/unknown usernames, failures, and artwork; public-paths.test.ts checks anonymous localized access.

Profile discovery across apps

The platform account menu and the shared Next satellite account menu offer Public profile for signed-in accounts with a canonical username. The destination is the central /u/[username] page, using the satellite’s configured central origin. Links open in a new tab so the current task and unsaved work remain available. Accounts without a username have no public-profile link; private profile settings remain available. No profile content or workspace data is copied across apps. Framework adapters without a canonical username omit the optional menu item. Regression coverage: public-profile-url.test.ts checks legacy usernames, configured origins, and rejected paths; satellite-user-menu-items.test.tsx checks signed-in, signed-out, and missing-profile menu behavior. The public /u/[username] page offers an explicit copy-link control using the canonical https://tuturuuu.com/u/[username] address. It copies only that routing URL, never biography text, account metadata, private fields, workspace identifiers or tracking query values. Default public fields remain avatar, banner, display name and biography; additional details still require explicit owner sharing. The control is an action for a visitor, not a new profile data field or a grant. It does not send messages, create notifications or publish anything automatically. The shared PublicLinkButton accepts only the canonical public-profile and published-notebook route shapes. The owning surface must first provide an already-public projection; URL validation is not authorization. It writes to the clipboard only after a click, announces success, and offers a read-only selectable URL after clipboard denial/unavailability. Duplicate clicks are fenced and old completion is suppressed when the destination changes or the control unmounts. App-provided English/Vietnamese labels keep the shared component free of implicit translation namespace requirements. Regression coverage: public-content-link.test.ts, public-link-button.test.tsx and profile page/projection tests. Hosted clipboard permissions and exact-head app/shared-package CI remain separate evidence gates.

Workspace User Avatar Paths

Web and Contacts user-avatar reads authorize the workspace through the existing permission helper, then use its resolved lowercase UUID. Aliases such as personal remain supported only when authorization resolves them to that UUID. The shared read handler signs only <workspace UUID>/users/<filename> in the workspaces bucket. It accepts exactly three original segments; it never repairs or decodes a submitted object path. Full URLs, other workspaces, nested paths, percent encodings, backslashes and control characters are rejected. The shared upload handler requires manage_users, a PNG, JPEG, WebP or GIF content type, and one filename of at most 255 ASCII letters, digits, dots, underscores or hyphens starting with a letter or digit. It preserves the filename and issues its avatars ticket only after charging the verified actor’s upload budget. These rules apply to shared handler callers; canonical Web and Contacts POST routes retain their existing optimized media-ticket flow. There is no DELETE handler in the shared user-avatar module. Regression coverage lives in packages/users-core/src/routes/users/avatar.test.ts. It exercises read and upload traversal, foreign workspaces, invalid resolved IDs, alias resolution, authorization and budget ordering with synthetic storage fixtures. Hosted Storage behavior is a separate acceptance boundary.

Workspace user API lifecycle

Web, Contacts, and mobile use the versioned /api/v1/workspaces/{wsId}/users collection and item contracts. The unused unversioned collection and item handlers are retired. The separate indicator migration endpoints retain their existing lifecycle. scripts/retired-workspace-user-routes.test.mjs checks the active route and inventory boundary.

Post Approval And Delivery Eligibility

Post approval lists and their counts use the selected workspace for both the post and its recipient. Web and Infrastructure email enqueueing and queued or immediate delivery require that the recipient and post group share a workspace. A queue row must also refer to that post’s group. An existing check is the recipient association. Moving to another group does not remove that association, cancel queued or retrying messages, or prevent a new enqueue for that check. Delivery recovery still recognizes provider-accepted messages and restores their recorded sent state without resending. Guest status is derived from membership in a group marked is_guest; it does not replace the existing check association or the workspace rule. Regression coverage lives in the approval read, queue-core, batch history and delivery recovery tests.

User data access boundaries

The Web and Contacts user routes apply workspace permissions before returning private user data. Sent-email history requires view_users_private_info. Post review lists, filter options and status require view_user_groups_posts or approve_posts; review rows mask email, recipient and phone fields unless the reader also has view_users_private_info. Referral lists retain public names for public-info readers and user editors, with email and phone masked unless the reader has private-info access. Post approval snapshots are read only after verifying that the post’s group belongs to the authorized workspace. Follow-up delivery resolves its recipient inside that workspace before sending. Announcement contact edits validate linked workspace users, including when updating an existing contact. User updates retain the route’s user ID and ignore a body-supplied identity key. Parameterized workspace summaries are a server-only helper. The Web workspace selector exposes only its existing zero-argument server action; authenticated Web and Hive API adapters can still pass their verified user identity internally. Regression coverage: packages/users-core/tests/routes/user-privacy-boundaries.test.ts, apps/web/src/__tests__/post-review-permissions.test.ts, and scripts/workspace-summary-server-boundary.test.js.