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 toemail_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_auditis created before a send and updated tosentimmediately after provider success.- Batch processing checks both
sent_emailsand matchingemail_auditentries 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
sentand backfillssent_emailsopportunistically instead of resending. - If provider delivery succeeds but local
sent_emailspersistence fails, the queue row still transitions tosentwith a warning inlast_errorso 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; strandedprocessingrows 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_SECRETserver-side and invokes/api/cron/process-post-email-queuewith diagnostic output. - The cron defaults are tuned for production backlogs:
limit=500andsendLimit=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.datestring values withgte(startDate)andlte(endDate)to avoid timezone drift. - Attendance exports must bind both
workspace_user_groups.ws_idand joinedworkspace_users.ws_idto 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 globalusers.display_name. - The workspace members settings page can edit display names for joined members and pending invites.
- Pending email invites may create a
workspace_usersrow before the invited platform user joins. - When the invited user later joins,
workspace_user_linked_usersshould reuse exactly one unlinkedworkspace_usersrow 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_membersbefore 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_membersinsert. 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-inviteroute 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_detailsand 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_membersin that workspace (andmanage_workspace_roleswith 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.sqlandapps/web/src/__tests__/workspace-accept-invite-route.test.ts.
Workspace user read permissions
Session-based user lists and web user detail reads requireview_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=falsehides members whose role in that group isTEACHER(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_MANAGERScontrols 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.
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 MBwork_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_uidbefore 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_idorupdated_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
reorderedevents when the persistedsort_keychanges.
Workspace Validation
- Before any admin write, normalize the route
wsIdand verify the target group belongs to that workspace. - Member writes must also verify every affected
workspace_users.idbelongs 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_usersrow before inserting or deletinguser_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_idfrom the URL without checking ownership.
Topic Announcements
Topic Announcements live under Users and are gated by the workspace secretENABLE_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_groupsrow throughgroup_idfor 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 as0.0.0.0:7803never leak into recipient inboxes. SetTOPIC_ANNOUNCEMENT_VERIFICATION_ORIGINonly 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;roomstays 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 throughGET /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 = queuedandscheduled_send_atusing the workspace’s fixed IANA timezone from calendar settings. Scheduling is blocked while the workspace timezone is stillautoor unset; use the same calendar timezone gate as the main calendar product. - The
process-topic-announcement-queuecron job (every 15 minutes) loads due queued rows and reuses the samesendTopicAnnouncementpath as manual send. Failed sends keep the existingfailed/last_errorhandling without infinite retries. - Announcement list APIs enrich recipient rows with the same
verificationStatuscontract as the contacts list so the table does not treat missing status as unverified.
Implementation Notes
- Client UI must call
@tuturuuu/internal-apiTopic 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-bulkhelper 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_membersfor 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 (migration20261026192000). Drive permissions (view_drive,manage_drive) never grant direct Storage API or Drive-route access to them; only the feature gate plusmanage_userscan sign uploads. Coverage:apps/database/supabase/tests/workspace-storage-least-privilege-rls.sqland 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
SendEmailCommandpath.
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 andGET/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_idand its name), date, start and end time, room, campus, the GVCN asteacher_workspace_user_id, and the GVNN as its one recipient contact. It also stores the 24-hoursend_due_atdeadline 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
TEACHERmember inworkspace_user_groups_users. With several teachers the earliest assigned (membershipcreated_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
409with 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_appliedwith 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).
- claimed before the save: nothing changes (
- Sending schedule sessions. “Send now” and bulk send claim a schedule
session first; a session already queued or sending answers
SEND_CONFLICTand a sent oneALREADY_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 markedfailedwithNO_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 sessionfailedwithPROCESSING_TIMEOUT15–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_assignedin-app notification andnotify-teacherssender: 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.
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.
topicmay be empty only whilestatus = 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_idmust be a workspace user of the same workspace (trigger-enforced). Deleting that user clears the assignment. Atopic_assignednotification 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_timein 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_atis set. Late:sent_atis null andnow() > send_due_at. Not sent: otherwise. There is no status column, sweep, or cron for lateness. A resend to a substitute bumpsdelivery_revisionand keeps the firstsent_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_bookis at most 200 characters.video_urlmust be anhttps://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 secretENABLE_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_usersreads 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 requiresmanage_usersplussend_user_group_post_emails. - Assignment-scoped teachers. Without
manage_users, a member may list and preview a topic withcreate_user_groups_postsorupdate_user_groups_posts, and may upload page images or attachments for it and edit it only withupdate_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 isteacher_workspace_user_id, or, only while no teacher is assigned, they are aTEACHERof 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 requiresend_user_group_post_emailsplus the same assignment check. Topics outside the assignment answer 404; a member with no linked workspace user is denied.ENABLE_TOPIC_ANNOUNCEMENTSremains the gate for both scopes. - Teacher write limits. Teachers may change only
topic,body,courseBook,videoUrl, andattachmentDrafts, and may setstatusonly todrafton 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=trueis 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 alwaystopic 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_REQUIREDfrom 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_atthrough@tuturuuu/utils/topic-announcement-deadline, using the workspace’s fixed calendar timezone. Without a fixed timezone the deadline stays empty.getTopicAnnouncementDeliveryStatederives Sent, Late, and Not sent. - Save and notify.
POST .../topic-announcements/notify-teacherscreates onetopic_assignedin-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_userswho 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 withsend_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
teacherWorkspaceUserIdthrough the update route, the new teacher gets atopic_assignednotification (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).
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)
Migration20261029050200_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) andtopic_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_notificationanswers false for their email channel, socreate_notificationrecords 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_noticesholds one receipt per announcement, kind andsend_due_at, claimed withinsert ... on conflict do nothingbefore notifying. A rerun, restart or overlapping run never notifies twice, deleting the notification keeps the receipt, and a newsend_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 byapps/database/verification/topic-teacher-deadline-notices-rollback-roundtrip.sql.
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 nonemptytoken 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_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 reusePOST /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 inapps/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.
Explicit public link copying
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 aspersonal 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 markedis_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 requiresview_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.