ESP32-S3 Bench Guide
ESP32-S3 N16R8 bench guide: live pinout, lessons, simulators and the bench log.
翻译准备中
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗AI-powered didactic planning platform for teachers.
Vercel Hobby: máximo 12 funciones por deploy. Los endpoints
/api/class/*y/api/notify/*se sirven con una sola función cada uno (api/class.js,api/notify.js, con rewrites envercel.json; en Vercel las rutas dinámicas[x].jsno funcionaron) y los destinos devercel.jsonno llevan/public(Vercel sirvepublic/en la raíz); los handlers viven enapi/_lib/(las carpetas con_no se despliegan como funciones). Antes de agregar un archivo nuevo enapi/, contá las funciones (hoy: 10).
public/index.html)supabase/migrations/001_base.sql)api/generate.js) that calls Azure OpenAI (gpt-4o). The Azure key lives only on the server.public/groups-calendar.js / .css, public/csv-import.js (CSV parser, also unit-tested in node), api/calendar/feed.js (calendar subscription),
api/_lib/notify/session.js (email invitations, optional), api/_lib/ics.js.public/class-doc.js / .css, public/attendance.js, and the vendored editor
public/vendor/classdoc-editor.js (Yjs 13.6.32 + TipTap 2.27.2, one pinned ES module loaded on demand; no build step —
it is rebuilt only when upgrading, with scripts/vendor/build-classdoc.sh).public/class-board.js / .css and the vendored Excalidraw public/vendor/whiteboard/
(React 18.3.1 + ReactDOM 18.3.1 + @excalidraw/excalidraw 0.18.1, MIT; one pinned ES module + lazy chunks + fonts, loaded only
when a board is opened; rebuilt only when upgrading, with scripts/vendor/build-whiteboard.sh).public/group-notebook.js / .css (same vendored editor as the class
document; the board pages live in public/class-board.js).| Name | Example |
|---|---|
AZURE_OPENAI_ENDPOINT | https://<resource>.openai.azure.com/ |
AZURE_OPENAI_API_KEY | (secret) |
AZURE_OPENAI_DEPLOYMENT | gpt-4o |
AZURE_OPENAI_DEPLOYMENT_MINI | (optional) gpt-4.1-mini — the name of a second deployment in the same Azure resource (owner checklist below). Used by Platform → Compare models and by the kinds of request switched to "Mini model" in Platform → AI model. Without it, "mini" falls back to AZURE_OPENAI_DEPLOYMENT (the screens say so). |
AZURE_OPENAI_API_VERSION | 2024-12-01-preview |
SUPABASE_SERVICE_ROLE_KEY | (secret) Supabase → Project Settings → API Keys → service_role (or a new sb_secret_… key). Server only: turns on AI token limits and usage recording. Without it the app works without token limits and Platform shows a warning. |
RESEND_API_KEY | (secret, optional) Turns on class invitation / update / cancellation emails with the event attached (resend.com), and the email to the institution admins when someone does not agree to be recorded (that one also needs SUPABASE_SERVICE_ROLE_KEY, to read the admins' emails on the server). Without it nothing is sent: the app offers "Copy invitation link" and the justifications are only in Groups → Consents. |
NOTIFY_FROM | (optional) Sender of those emails, e.g. TeroClass <[email protected]> (the domain must be verified in Resend). Default TeroClass <[email protected]>. |
APP_URL | (optional) Public https origin used in links inside calendar events, e.g. https://teroclass.com. Default: the request's host. |
SALES_NOTIFY_EMAIL | (optional) Where "Hablar con ventas" requests are e-mailed, e.g. [email protected] (never hard-coded). Needs RESEND_API_KEY too. Without them requests are only in Platform → Sales requests, with a red badge on the Platform icon. |
Run supabase/migrations/001_base.sql in Supabase → SQL Editor (safe to re-run). It changed again for the recording pipeline
(2026-10-28, block RECORDING PIPELINE, see Grabación real): run it again — tested from git 08ff5fc with data, then twice,
then 002_live_class.sql (unchanged; still optional and still applies after it). Nothing is recorded until the owner sets the
variables. Before that it changed for the group notebook
(2026-10-28, see Group notebook) — tested from git b0afe36 with data, then twice; 002_live_class.sql did not
change and still applies after it. Before that it changed for plans and the free
trial (2026-10-21, see Plans, free trial and sales) — tested from git a4f00d3 with data, then twice; 002_live_class.sql
did not change and still applies after it. The first run marks every existing institution as "Pilot" (nothing stops working);
set the ones that are not paying to "No plan" in Platform. Before that it changed for the class whiteboard
(2026-10-21, see Class whiteboard) — tested from git 3c0ef7b with data, then twice; 002_live_class.sql did not
change and still applies after it. Before that it changed with the pre-launch
fix sprint (2026-10, see Pre-launch fix sprint) — tested from git 203e2e1 with data, then twice; earlier: (it upgrades the
previous version in place and keeps all data; tested from git 9993462 — and c198c60, d063eb0 — with data, then twice).
002_live_class.sql is still not needed; it changed too (its per-call consents table is now an audit log written only by
the server from user_consents) and it still applies after the new 001 (it now requires it).
The super admin role lives only in profiles.role. The script never promotes anyone by email and never
demotes an existing super admin, so re-running it keeps the current owner. To grant the role (once, in
Supabase → SQL Editor; replace the placeholder with the real account email):
update public.profiles set role='superadmin' where id=(select id from auth.users where email='OWNER_EMAIL_HERE');
The owner's identity (name, email, id) is never shown to institution admins or teachers: their profile is hidden by RLS, and "created by / invited by / uploaded by / decided by" references to a super admin are removed or masked (shown as "TeroClass team").
Institutions form a hierarchy of up to 3 levels: main institution › campus/faculty › department/area
(institutions.parent_id). Admins of a unit manage everything below it; sub-units inherit branding,
master prompt, playbooks and learning-approach bars (get_effective_config).
Next to the logo, the institution switcher picks the unit every institution screen works on: Structure, Dashboard, Branding, AI Settings and Plan & usage show that unit and everything below it. Super admin: All institutions (platform screens: Institutions, Metrics, Platform; and Institution → Insights) or any unit of the tree (searchable when there are more than 8). Admins: only their own subtree (a single unit is shown as plain text). Teachers: no switcher. The choice is remembered per person in the browser and checked against what they can access; Institutions in the sidebar = All institutions; View on an institution card selects it. During View as it is locked to the viewed unit. The sidebar shows a label under each icon, grouped as Teaching · Institution · Platform (icons only on phones).
Metrics (Spanish: Métricas; called "Insights" before 2026-10-18) shows product usage for the last 7 / 30 / 90 days, for all institutions or the selected unit and what is
below it (optionally without the owner's own activity): classes generated per day, active teachers (1/7/30 days), new and
returning users, generated vs saved classes, student handouts, top institutions; top subjects, teaching languages, age
bands, average learning-approach bars, institutions with a playbook; AI tokens (input/output) and an estimated cost in USD
(prices per 1M tokens in Platform → AI cost estimate, default GPT-4o 2.50 / 10.00).
Every AI request is recorded with its class settings (never the prompt text) in ai_events by consume_ai_quota,
with the user's own session, so this works without the server key; institution and user come from the profile.
Token counts and costs come from the server log and need SUPABASE_SERVICE_ROLE_KEY (the screen says so when it is
missing). Data is read only through platform_insights (super admin; totals only, no names; the RPC kept its name), which also
counts the classes with class insights in the period (students.insight_sessions).
Insights = what each person can learn from a class (the analytics screen above is Metrics):
Real insights (recorded classes). With the recording pipeline on (Grabación real), a calendar class taught in the real
video classroom gets REAL insights (is_demo = false) from its recording: transcript (Azure Speech) → teacher report and the
insights of every student who consented (Azure OpenAI, same JSON shape and screens as the demo; talk time, words per minute,
questions, wait time and the longest teacher turn are computed in code from the timestamps). While it is processing, Insights
shows "Procesando tu clase… (puede tardar unos minutos)" (in the list: "Procesando…" instead of Generate demo insights) and
asks the server to advance every ~20 s (POST /api/class/process); real insights replace the demo ones of that class (the
reflection and messages are kept). Students who did not consent get nothing real (a demo row, if one was generated, stays demo).
Data (until then: demo). Without the recording pipeline,
demo insights are stored for real classes and flagged is_demo (the screens show "Demo data — real insights activate when
the live classroom is connected"):
Database (001_base.sql, no direct table access; security-definer functions only). teacher_insights (one per calendar
class: class_sessions) and student_insights (one per class and enrollment, so a student is the person the institution
loaded; names and emails are never stored in the insight data). Functions: generate_demo_insights (teacher / substitute of the
class or an admin of its unit; payload built by public/insights-view.js), list_insight_sessions, get_session_insights,
save_teacher_reflection and save_insight_message (teacher / substitute only), my_insights / my_insight (the student's own;
super admin with an enrollment for View as). Privacy: a student sees only their own insights and their own published
message (never drafts, the teacher report, the reflection or classmates); teachers see the classes of the groups they teach or
substitute in (and their one-off classes); admins only their subtree; the reflection is visible to the teacher and the unit's
admins, never students.
Reconciliation with 002_live_class.sql (not run in production): insights belong to the calendar class, not to the call,
so the tables moved to 001; 002 now only links them to the call — live_sessions.class_session_id (the class taught in that
call) and student_insights.participant_id (FK to session_participants) — drops its old insight policies and requires the new
001. Running 002 after 001 (any number of times) changes nothing else. A database where an old 002 had created insight
tables tied to live_sessions keeps them as *_live_legacy (empty ones are dropped).
Limitations (class insights). Without the recording pipeline all insights are sample data (same content for
every class of a subject type, varied per student; real numbers, quotes and pronunciation audio need the recording); the
assistant draft is a template built from the student's feedback, not an AI call; a guardian has no access to a minor's
insights yet; access follows the group: a teacher replaced in a group stops seeing its past classes' insights and the new one
sees them; removing a student from the unit deletes their insights; generated demo insights can't be removed from the app.
The platform owner can see the app exactly as an institution sees it, without creating accounts: Platform → 👁 View as…, the account menu, or 👁 View as on any unit in Structure. Pick a unit (any level), a perspective (institution admin or teacher) and optionally a person of that unit (to see their saved classes). The whole UI then behaves as that role in that unit (navigation, mode switch, inherited branding, learning-approach bars, prompt/playbook chain, Structure limited to that subtree, Plan & usage, Dashboard, Live class, Home). It is a client-side overlay (auth is not touched) and strictly read-only: every save/invite/role change/upload/ purchase/approval is blocked ("View as is read-only"), also at the Supabase-client and fetch level. Generating a class is allowed as a preview with that unit's settings; it is never saved and the AI tokens count to the owner. A persistent banner shows who you are viewing as; Exit restores your session; a reload always starts normally.
Each main institution is a billing account: seats (teachers + admins in its whole tree) and a monthly allowance of AI
tokens (calendar month, UTC), shown to admins as classes (1 class = 25,000 tokens; see Plans, free trial and sales). When the
allowance is used up, purchased class packs (extra balance, no expiry yet) are used. There are no payments in the app: an
admin of the main institution requests a pack (50 classes = USD 25, 200 = USD 85) in Plan & usage, the super admin charges
outside the app (transfer / Mercado Pago) and approves it in Platform, which adds the classes. Pack prices live in the plans
(Platform → Plans & pricing); the free monthly allowance for users without an institution and the defaults for new institutions
are in Platform → Defaults. Token accounting happens only on the server (api/generate.js → ai_budget_check /
ai_record_usage, executable only with the service key).
Pricing proposal v1 (plans.json), as decided by the owner: Free trial 5 classes in total · Docente USD 12/month or
USD 99/year (EUR 11.99 / 99, VAT incl.) · Institución per teacher per month, billed yearly: USD 14 (3–9), 12 (10–29), 10 (30–99);
monthly ≈ 20% more (17 / 14.50 / 12) · Institución+ custom (reference USD 9 + onboarding) · class packs 50 = USD 25, 200 = USD 85,
analyzed-class packs, AI tracking, classroom-hour pack · launch offers (off until switched on). There is no online payment yet:
a plan is activated by hand in Platform once paid (Uruguayan SAS: transfer + e-invoice; Paddle / Polar later for self-serve).
platform_settings.plans (seeded once from plans.json, never overwritten by a re-run) → get_plans()
(public, also without a session). Platform → Plans & pricing edits prices, limits, packs and the launch-offer switches
(update_plans, super admin only, validated). Texts live in public/plans.js (Spanish "vos" + English) and always take the
numbers from the data.credit_tokens). Plan & usage shows classes this month / left, extra
classes from packs, the account's plan status and the class packs to request (request_token_pack(p_pack); the old 10M-token
pack for USD 11.99, sold at a loss, is gone; old requests and balances are kept and shown as ≈ classes). Server messages talk
about classes and class packs.billing_accounts.plan_status ∈ pilot, active, founder,
enterprise (others: none, cancelled), set by the super admin in Platform → Institutions: plan and usage ("Apply plan" also
sets the monthly classes to seats × classes per teacher). People without an institution get a Teacher plan in Platform →
Teacher plans (individual_plans; 25 classes/month).consume_ai_quota
counts completed main generations (ai_events kind generate, ok), lifetime; in-flight ones count too (two requests at once
can't pass the limit; one advisory lock per person); failed ones don't — and only the server can mark a request as failed (it
holds the request's finish_key). At the 6th → trial_limit → /api/generate 402 {code:'trial_limit'}. Follow-up
requests (continuation, calculation check, student handout) are allowed only right after a counted class (2 h, max 6), so
"continue" can't be used to get classes for free.public/plans.js): a persistent banner "Te quedan N clases de prueba · Ver planes" (amber at the last
one, red when used up); after the 2nd completed class, 3 s after the class is on screen, the plans pop-up (once:
, ); after the , a short reminder; when the is attempted the app opens the plans pop-up
(and the server refuses it anyway). Everything already created can still be viewed and exported; trial PDFs carry the watermark
"Creado con TeroClass — versión de prueba". A teacher of an institution without a plan gets the variant "Tu institución todavía
no tiene un plan activo" with (copies a short message with the /planes link). After a sales request, no
more pop-ups (banner only). Copy is honest: no countdowns, no invented scarcity, no "most popular" claim; the Founding offer only
says "N places left" when real accounts already have it.Database (001_base.sql). platform_settings.plans, ai_model_default, ai_price_input_mini, ai_price_output_mini;
billing_accounts.plan_status / plan_id / plan_activated_at / plan_changed_at; token_purchase_requests.pack_id / pack_credits;
ai_events.finish_key; tables individual_plans, trial_state, sales_leads, model_comparisons (RLS on, no grants:
security-definer functions only). Functions: get_plans, update_plans, my_trial_status, trial_mark,
set_institution_plan_status, set_individual_plan, list_individual_plans, submit_sales_lead, submit_sales_lead_service,
sales_lead_notified(_service), list_sales_leads, update_sales_lead, sales_leads_new_count, save_model_comparison,
model_comparison_summary, get_model_comparison, update_ai_model_default, update_ai_prices_mini, platform_funnel;
consume_ai_quota(p_meta, p_est_tokens, p_model) and finish_ai_event(p_event, p_ok, p_key) replace the old signatures (calls
with the old arguments keep working).
Limitations (plans). No online payment or invoicing: plans, Teacher plans and packs are activated by hand. Packs still don't expire (one balance per account; the 12-month expiry of the proposal needs pack lots). Only the class limits are enforced: video hours, analyzed classes, storage, methodologies, participants and the per-plan feature switches (calendar, CSV import…) are shown on the plans page but not limited in the app yet. The trial is per person (5 classes); the proposal's pooled 15-class trial for an institution without a plan was not built. The 24-hour gap between pop-ups and the e-mail sequence of the proposal were not built. The watermark is added by the browser's print view (a determined user can remove it). Prices by purchasing power (zone C) are not shown. A trial person can still get up to 6 follow-up requests right after each counted class.
Model. Institution › campus › department › group (e.g. "English B1 – Tue/Thu"). A group belongs to one unit, has one teacher and optionally one substitute (same access to the group's classes and students), and many students. A group can exist without a teacher for a while (e.g. created by a CSV import); the Groups screen shows "Not assigned".
Students are a role (student) and are free: they never use seats (seats count teachers + admins only) and they don't
belong to a unit (profiles.institution_id stays empty). Their link to an institution is an enrollment
(student_enrollments): "this unit loaded this email (and name)". The email is stored exactly as loaded.
Institution admins (Institution → Groups, for the unit chosen in the switcher and everything below it): create /
edit / delete groups, assign or change the teacher and substitute, add students one by one (email + name, optional group),
import a CSV (below). Move a student to another group, remove from a group or from the unit.
The teacher or substitute can be someone not registered yet: in Edit group pick an invited email (or Invite someone
by email…); they get an invitation as teacher of that unit (same permission and seat rules as Invite), the group shows
Pending: email (invited), and they are assigned automatically as soon as they register or accept the invitation
(trigger profiles_claim_pending_groups; also when an existing account is added to the unit). Pending emails are only
returned to admins of that institution (list_groups).
CSV import (groups, students and their schedule). One row per student; public/csv-import.js reads the file in the
browser (no build) and the database re-validates everything (import_students).
email, name/nombre, group/grupo, unit/unidad
(name or slug), teacher_email/docente, substitute_email/suplente, days/días, start_time/hora/hora_inicio,
end_time/hora_fin or duration/duración (minutes), start_date/desde/inicio, end_date/hasta/fin,
timezone/zona. Comma, semicolon or tab; up to 2,000 rows; downloadable template (Spanish headers in Spanish UI).session_series.origin = 'csv') with its classes (up to one year). Only future classes
are created (dates already passed are skipped and counted in the preview).tc_series_apply); past classes are never touched or deleted. A group that already has another teacher keeps
them (warning in the preview).
A read-only Calendar tab lists the unit's classes for the next 4 weeks.Teachers and substitutes (Teaching → Calendar): week view and agenda (the agenda is the default on phones).
Schedule class: a weekly series for a group (weekdays, start time, duration, from/until — up to one year; times use the
group's time zone; every class is stored as a row) or one class, for a group or without a group inviting students by
email. An email that is not yet a student of the institution is loaded by that teacher into their unit (same privacy rules).
Edit or cancel one class (a class edited on its own keeps its changes when the series is edited) or the whole series
(from today on). Everything is stored in UTC (timestamptz) and shown in the viewer's local time, with the time zone
labelled. Enter room / Join opens the existing live-class demo for that session id (same room for its teacher and
students). The teacher and substitute can always enter a class that is not cancelled (to prepare or test) from the
Today's / Next class card at the top of the Calendar, the week-view event detail and the agenda list; outside class time
it says "Students can join from 10 min before". Students can join from 10 minutes before the start until the end;
before that the button is disabled and says "Opens at HH:MM" (in their time zone).
Privacy (enforced in the database, not only in the screens). The new tables have no direct access at all; everything goes through security-definer functions:
profiles;ai_events never gets student data (students can't generate); Metrics shows only totals (loaded students, signed in,
with classes in the period, groups, classes held, classes with insights).Student sign-in: a link by email, no password. The login screen has I'm a student → get a link by email
(supabase.auth.signInWithOtp; also /?student). After the link is opened, claim_enrollments() links every enrollment whose
email equals the account's verified email (case-insensitive) — only when that session was opened from an email link /
code (Supabase amr claim), so a password sign-up with someone else's email can't take their classes. New magic-link
accounts get the role student and only see their dashboard; without enrollments they get a friendly "no classes yet"
screen and no access to the generator. Teacher registration and invitations work as before (an invited email that uses the
link still joins as the invited teacher/admin). A teacher/admin who is also enrolled keeps their role and sees those classes
in their Calendar ("As a student").
Supabase settings to check: Authentication → URL Configuration: Site URL = the production URL and Redirect URLs
including it (and the Vercel preview URL), otherwise the link lands on the wrong site. Rate limit: Supabase's built-in
email sender allows only a few emails per hour for the whole project — fine for testing, not for a class. Pending for
Ignacio: configure a custom SMTP (Authentication → Emails → SMTP Settings, e.g. with @teroclass.com) before real use.
Student dashboard (nothing else: no generator, bars, admin screens or switcher; language toggle and error toasts stay): 📅 Calendar (upcoming classes), ▶️ Enter class (the next/current class with a big Join button), 💡 My insights (their newest insights, or an empty/preview state; See all my insights opens the full list — see Class insights).
Calendars without an email provider. Every class has Add to Google Calendar and an .ics download. Each teacher and
student has a personal subscription feed: /cal/<token>.ics (→ api/calendar/feed.js; also
/api/calendar/feed?token=…). The token is random and can be reset (the old link stops working); the feed is built by the
calendar_feed function called with the publishable key and contains only that person's classes (no emails, no other
students' data, cancelled classes marked as cancelled, times in UTC). The UI offers Subscribe in Google Calendar, Copy feed
URL, Outlook / Apple Calendar (webcal) and Reset link. Email notifications (api/_lib/notify/session.js) are a "dummy
feature" like the live class: they send only when RESEND_API_KEY exists; otherwise the app shows "email not configured"
and offers Copy invitation link.
View as → Student (super admin): pick a unit, then a group, optionally a student — the student dashboard, read-only.
Minors and consent. Many students are under 18: their "yes" to recording needs a parent/guardian's name and email and
the student's declaration that the guardian agrees (shown as "declared by the student", not verified), unless the
institution turns on institutions.consent_managed_by_institution (Dashboard → Recording consent of minors): then it
declares it collected the families' consent itself and minors are not asked for guardian details. TODO: real guardian
verification by email.
Limitations. A CSV import creates only future classes (past dates in the file are skipped); a CSV never replaces a
group's current teacher (only fills an empty or pending one); one imported schedule per group (one start time for all its
days); a schedule without an end date is reported as incomplete; a pending teacher is assigned by the email they register
with (with "Confirm email" ON, see the header of 001_base.sql); with no free seats the pending email is kept but no
invitation is sent. Editing a whole series moves only future classes that weren't edited one by one; changing a group's time
zone doesn't move classes already scheduled; a one-off class's invitees are set when it is created; the live class is still
the demo (real video needs LiveKit and a session-aware token: api/_lib/class/token.js); a student who changes their account
email keeps the classes already linked.
One shared document per calendar class, like a Google Doc: everyone in the class reads and types at the same time, the teacher can paste the class there, and it stays as a record afterwards. Since 2026-10-28 a class of a GROUP uses the group notebook instead (see Group notebook): this per-class document is now used by one-off classes without a group, and the ones written before stay readable as the previous document of their class.
How it syncs. Editor = TipTap 2 + Yjs (CRDT). Every local change is sent to classdoc_push (a Yjs update, base64; the
database checks access, the lock and the size, and numbers changes in order); everyone reads new changes with
classdoc_pull; an editor sends a compact snapshot now and then (classdoc_snapshot) and older changes are deleted.
Realtime only carries "there are changes, read them" pokes and cursor positions, never the document content, on the
private channel classdoc:<class id>, authorized by the same access check through policies on realtime.messages
(tc_classdoc_listen: anyone with access; tc_classdoc_send: teacher and students). If private channels are not available
the app uses a public channel with an unguessable name returned only by classdoc_open (same rule: no content travels on
it); if Realtime is not available at all it reads every 2 seconds and shows "Live sync unavailable: updates every few
seconds". Names and colours of the people in a document come from the database (classdoc_pull presence), never from other
browsers. Tables: class_docs, class_doc_updates, class_doc_versions, class_doc_presence (no direct access).
Supabase dashboard steps (Ignacio):
001_base.sql (it creates the two policies on realtime.messages; check them in Database → Policies →
schema realtime → messages: tc_classdoc_listen and tc_classdoc_send).realtime.messages did not exist when you ran 001, run 001 again after enabling
Realtime (it creates the policies only when that table exists). Without the policies everything still works by polling.Limitations (class document). Fast typing reaches others in well under a second with Realtime, every 2 s without it; cursors only move live with Realtime (without it they refresh every 2 s); the names on the cursors are the ones the school loaded; a student removed from the group loses access immediately, but a browser that is already open keeps what it has already loaded; a restore discards edits that someone was typing at that very moment; images, colours, comments and suggestion mode are not supported; there is no offline editing (unsent changes are retried while the page is open); the teacher-only private note for the teacher guide was not built.
One shared drawing board per calendar class (Excalidraw): everyone in the class draws at the same time, the teacher can lock it, and it stays as a record afterwards. Since 2026-10-28 the board of a class of a group is a page of the group board (a page selector inside the board; see Group notebook).
es-ES texts). Images and embedded web pages are disabled for now (tool hidden, a pasted /
dropped image is removed with a message, and the database refuses them) — they would need file storage; the library
panel and "Mermaid → diagram" are hidden too.How it syncs. Each browser sends only the elements it changed to classboard_push (~300 ms after the last change,
at least every 1.2 s while drawing). The database validates every element (allowed type, id, version, versionNonce,
max 256 KB, no customData, links only http(s) / mailto) and keeps, per element, the highest version (same version: the
lowest versionNonce — Excalidraw's own reconcile rule), numbering every accepted change (rev) so classboard_pull
returns only what is new. Cap: 2 MB per board ("La pizarra llegó a su tamaño máximo (2 MB): borrá dibujos que ya no
uses…"; deleting frees space; a warning shows from 80 %). On the private Realtime channel classboard:<class id> the
changed elements also travel so the others see a stroke while it is drawn, plus the pointers (names and colours come from
the database through classboard_pull, never from other browsers). Nothing is stored from the channel: a browser only
draws what it receives (after the same type / size checks) and reconciles it with reconcileElements; anything that no
pull confirms within a few seconds is replaced by the stored version or removed. The public fallback channel (unguessable
name from classboard_open) only carries "changes, pull" pokes and pointers; without Realtime the board reads every 2 s and
says "Live sync unavailable". Tables (no direct access): class_boards, class_board_elements, class_board_versions,
class_board_presence.
Library and CSP. Excalidraw needs React: public/vendor/whiteboard/whiteboard.js (1.30 MB, 423 KB gzip, plus
excalidraw.css 145 KB / 23 KB gzip) is loaded with import() only when a board opens; its lazy chunks (Spanish/English
texts, PNG/SVG export helpers) and fonts are in the same folder (2.1 MB in total; the CJK font Xiaolai, 13 MB, is left out:
Chinese/Japanese text uses the system font). window.EXCALIDRAW_ASSET_PATH points at /vendor/whiteboard/ and the build
also replaces Excalidraw's CDN fallback for fonts, so nothing is requested from another origin; font subsetting for SVG
export (WebAssembly) is replaced by embedding the whole (already small) font file, so the CSP did not change (no
wasm-unsafe-eval). tests/e2e-csp.cjs opens a board, draws and exports under the real headers.
Supabase dashboard steps (Ignacio):
001_base.sql. It creates two more policies on realtime.messages: check Database → Policies → schema
realtime → messages: tc_classboard_listen and tc_classboard_send (next to tc_classdoc_listen / tc_classdoc_send).Limitations (class whiteboard). No images or embedded pages yet; one board per calendar class — quick rooms started outside the calendar have no board (they are not in the database: their access is a signed link checked only by the server; schedule a one-off class to get one); pointers move live only with Realtime (every 2 s without it); a restore discards strokes that someone was drawing at that very moment; two people moving the same shape at the same instant: the last saved version wins; Excalidraw's own Spanish texts are from Spain (and a few of its hints are still in English); the shared shape library isn't available; no offline drawing (unsent changes are retried while the page is open); a student who was already connected when the board was locked can't send drawings (the app stops, the database refuses them and the others' screens drop anything not stored), but the Realtime policy itself is checked when joining the channel.
One shared notebook per group and school year, with one section per class in date order ("Clase 12 · lun 3 mar"), and the group board with one page per class. The owner's words: "each class should have its annual whiteboard, to record session by session, and both students and teachers can edit; the class generated with the AI can be copied into it."
set_school_year_start). Computed in the group's time zone. Title: "Cuaderno · · 2026" ("2026–27" when the year starts
after March).data-sec) and the notebook opens on today's section
(or the class it was opened from). Cancelled classes get none. Sections are never deleted.notebook:<id> with pokes and cursors only, polling fallback); institution admins of the
unit (and above) and the super admin read only (Groups → group card → Cuaderno); View as is read-only. Nobody else
(checked in the database on every read and write). Names come from the database; no emails anywhere.Database (001_base.sql, block ══ BEGIN GROUP NOTEBOOK ══; no direct table access). institutions.school_year_start_month;
tables group_notebooks (group + school year, generation, lock / lock_past, bytes), group_notebook_sections (class or free
section; its Yjs photo), group_notebook_updates, group_notebook_versions + group_notebook_version_parts,
group_notebook_presence, group_notebook_notes (teacher-only). Functions: notebook_resolve / notebook_find (read-only, View as),
notebook_open, notebook_push, notebook_pull, notebook_snapshot, notebook_set_lock, notebook_add_section, notebook_versions,
notebook_version, notebook_add_version, notebook_restore, notebook_add_note, notebook_notes, notebook_delete_note,
notebook_targets, groupboard_pages, set_school_year_start, school_year_start; Realtime policies tc_notebook_listen /
tc_notebook_send on realtime.messages.
Supabase dashboard steps (Ignacio): run the new 001_base.sql and check Database → Policies → realtime.messages lists
tc_notebook_listen and tc_notebook_send. Nothing else (no new environment variable; still 10 Vercel functions).
Limitations (group notebook). The board keeps its lock per page (the notebook's lock does not lock the board); a restore of the notebook replaces all its sections (sections created after that version become empty; the "before restore" version keeps them); the "past classes" lock uses the class date in the group's time zone (a class edited to another day moves with it); a student added to a group later sees the whole year's notebook; teacher notes are plain formatted text (not editable in place; delete and send again); the generator's handout is the text of the handout tab (the same filter removes answers again); everything else as in the class document (no images, no offline editing, cursors live only with Realtime).
attendance_join (allowed from 10 minutes before the start until the end), sends attendance_heartbeat every minute
while they are in the room and attendance_leave when they leave or close the tab (if the tab dies, the last heartbeat
counts as the exit; minutes only count inside the class time). Late = joined more than N minutes after the start;
N = institutions.attendance_late_minutes of the unit or the nearest unit above, 10 by default (an admin can change it
with select public.set_attendance_late_minutes('<unit id>', 15);, no screen yet). Students with no record are absent
once the class has ended (computed when reading). The teacher's own presence is recorded too.set_attendance, source teacher: it wins over the automatic status, and the future AI/video data never overrides it).
Admins see the same list read-only.; separator in Spanish). Students added to the group later are
not counted for earlier classes.students.attendance in platform_insights = total rate, no names.attendance_record_external (executable only with the service key) is the hook for
api/livekit/webhook.js (participant_joined / participant_left → source auto) and for the AI participation estimate
(participation_seconds, source ai). Not wired yet; it never overrides a teacher correction.session_attendance (one row per class and enrollment; teacher rows with kind = 'teacher'), no direct access.Limitations (attendance). It only knows who entered the TeroClass room (the live class is still a demo: a student who joins by another channel is absent unless the teacher corrects it); minutes are counted with a one-minute heartbeat; one-off classes appear in the class detail but not in a group report; the late threshold has no screen yet (SQL / RPC only).
One checkbox, asked once. The first time a person enters any class room (students and teachers: the teacher's voice is
needed for the observation report) the lobby shows a single checkbox: "Acepto que mis clases en TeroClass se graben (solo audio)
y que la IA las analice para generar mis insights y mejorar las clases." It covers recording + AI analysis + storage (original
audio deleted after 30 days) and has a short Más información note. A yes is stored per person in the database and is
permanent: the next classes don't ask again while it is valid and the text version matches. When the text changes, bump
CONSENT_VERSION in public/live-class.js, api/_lib/class/token.js and tc_consent_version() in 001_base.sql (a test checks
they match): everyone is asked again.
guardian_source = 'student'), or nothing when the institution manages it (guardian_source = 'institution').RESEND_API_KEY + SUPABASE_SERVICE_ROLE_KEY exist (api/_lib/notify/consent.js); an empty "no" is only counted.revoked_at; the person is asked again at the next class, and can turn it back on from the same menu (same checkbox; guardian
fields for minors).; in Spanish). A red badge on Groups counts the justifications that arrived since the admin last opened the
tab (remembered per browser). View as shows it read-only.api/_lib/class/token.js ignores any consent sent by the browser; it reads my_consent() with the user's own
session and puts recorded, consent, minor, guardian_consent in the LiveKit metadata from there (never guardian data). If it
can't be read, the person joins not recorded.Database (001_base.sql). user_consents (user, version, granted_at, revoked_at + reason, minor + guardian name/email/consent;
one active row per person) and consent_declines (user, institution, class, role, version, text, notified_at). RLS: each person
reads only their own rows; justifications also by the admins of that unit's subtree (and the super admin); nobody writes directly.
Functions: my_consent, grant_recording_consent, revoke_recording_consent, decline_recording_consent, consent_overview
(admin), consent_new_declines (badge), platform_consent_counts (super admin, totals), consent_decline_notify_payload
(service role only: the admins' emails never reach the browser). Privacy: an institution sees only the people it loaded (names
and emails as it loaded them) and the justifications addressed to it; classmates and teachers see nothing; the owner never appears.
Reconciliation with 002_live_class.sql: its consents table (per call and participant) stays as an audit log: the server
(service role) copies the current user_consents row into it when a call is recorded (consents.user_consent_id); the browser can
no longer insert there, and revoke_consent() there also revokes the person's user_consents.
Limitations (recording consent). The recorder itself doesn't exist yet (classes are "recorded" only in the UI: indicator, badges, pause); revoking while already inside a room takes effect from the next class; a pause resumes after 5 minutes counted in the teacher's browser; the "new" badge is remembered per browser (another device shows the same justifications as new); the email goes to the admins of the unit and the units above it (not the super admin); a student loaded by two institutions has one consent, and each institution sees its status but only its own justifications; a guardian has no account or access of their own.
Findings TC-xx (technical audit) and P-xx (product audit). Summary of the behaviour that changed:
001_base.sql runs; admins turn it on in Dashboard → Join by link (with a warning: every person uses a seat).RESEND_API_KEY sent it), Dashboard → Invitations (copy link, resend, cancel), 14-day expiry. Inviting an
EXISTING account creates a pending invitation that the person accepts or declines in the app ("Ana invited you to join
… as …"); nobody is moved silently, a super admin is never moved or downgraded.resetPasswordForEmail → "choose a new password" (min. 8)./api/generate accepts only the app's own request kinds and
prompts (api/_lib/guard.js), per-kind max_tokens, a per-IP limit, requests per day, and estimated tokens per day
per teacher and per institution (Platform → AI daily limits; defaults 400,000 / 3,000,000). Accounts without an
institution: 3 requests / 30,000 tokens (unconfirmed email), 5 / 60,000 (first week), 15 / 150,000 after.finish_reason: "length" and the app asks for the
continuation in a second short request, so a long class is split instead of failing. (The browser still receives one
JSON answer: streaming to the browser would need a build step; this keeps every request under the limit.)api/class/ticket);
the role comes from the server; invented participants / sample plan / sample insights only in "🧪 Try the demo";
"Recording" only with RECORDING_ENABLED.Database: 001_base.sql and 002_live_class.sql changed: run 001 again, then 002 if you use it (tested from git
203e2e1 with data, then twice; 002 still applies after it).
SUPABASE_SERVICE_ROLE_KEY (monthly token budget), APP_URL=https://teroclass.com, APP_ENV=production
on production (and staging on preview), optionally TURNSTILE_SITE_KEY, SENTRY_DSN. RESEND_API_KEY /
NOTIFY_FROM and the LIVEKIT_* variables can now be loaded (TC-08 / TC-13 are fixed). RECORDING_ENABLED only
when a real recorder exists./org/*, /?reset=1), Confirm email ON, own SMTP
(e.g. Resend with @teroclass.com), minimum password length 8, Bot and Abuse Protection → Turnstile with the secret
key — TC-05 / TC-18. Realtime: turn off "Allow public access".001_base.sql; check tc_notebook_listen / tc_notebook_send on
realtime.messages; optionally set the month the school year starts in Dashboard → School year (e.g. March).001_base.sql and check that Database → Policies → realtime.messages lists
tc_classboard_listen and tc_classboard_send (see Class whiteboard). No new environment variable.gpt-4o → set a tokens-per-minute (TPM) limit that fits your plan. The app limits are a second
line of defence, not a bill cap./privacidad and /terminos, fill [RAZÓN SOCIAL], [RUT], [DIRECCIÓN], [EMAIL DE CONTACTO];
DPA with Supabase, Microsoft, Vercel, LiveKit, Resend; register the database with the URCDP (Uruguay); consent
model for minors (and whether guardian verification by email is required before recording minors).tests/e2e-mock.cjs, tests/e2e-qa-fixes.cjs, tests/e2e-hierarchy.cjs, tests/e2e-billing.cjs, tests/e2e-viewas.cjs, tests/e2e-nav-insights.cjs (switcher, sidebar, Metrics), tests/e2e-students-calendar.cjs (groups, CSV import, calendar, student magic link, View as Student, mobile), tests/e2e-csv-schedule.cjs (CSV with schedules, pending teachers, enter the room), tests/e2e-insights.cjs (class insights per role, demo generation, reflection, publish a message, student privacy, admin subtree, View as, mobile, Métricas), tests/e2e-classdoc.cjs (two browsers editing the same class document through a simulated Realtime broadcast, lock, paste a class without answers, admin read-only, live room tab, polling fallback, history/restore, export, mobile), tests/e2e-classboard.cjs (class whiteboard: two browsers drawing on the same board through the simulated Realtime channel, lock, broadcasts never stored, admin read-only, live room tab + full screen, polling fallback, history/restore, PNG/SVG export, images refused, phone with touch drawing; SHOTS=<dir> saves wb-*.png) tests/e2e-attendance.cjs (join → present for the teacher, corrections, admin group report + CSV, student summary, mobile) and tests/e2e-consent.cjs (one checkbox asked once, decline + required justification → admin Consentimientos + badge + CSV, profile revoke / turn on, minor + guardian, auto-recording + pause, View as, Métricas, mobile; SHOTS=<dir> saves cons-*.png) run the app in a headless browser
against a simulated Supabase (NODE_PATH=/opt/npm-tools/node_modules node tests/<file>).
Pre-launch fixes: tests/e2e-launch-fixes.cjs, tests/e2e-sprint-fixes.cjs (SHOTS=<dir> saves fix-*.png) and
tests/e2e-csp.cjs (the app under the real vercel.json headers / CSP; also opens the group notebook and the board page selector); tests/api-launch.test.mjs and
tests/sql/launch_fixes.sql (also run after an upgrade from git 203e2e1 with data).
Plans and free trial (2026-10-21): tests/e2e-plans.cjs (trial pop-up at the 2nd class, reminder at the 4th, hard block at the 6th
with the plans pop-up — also when the server refuses it —, watermark, active-plan teachers / super admin / students never see it,
/planes ES + EN desktop + mobile, sales form → the super admin's list (status, notes, CSV, badge), "Quiero el plan Docente", Plan &
usage in classes + packs, plans editor + launch offers + plan status + Teacher plan, compare models with two mock outputs and the
rating saved, the per-kind model setting; saves ), ( 402, finish
key, model accepted only from the super admin, mini deployment + fallback, sales form API: session / Turnstile / e-mail / errors)
and (get_plans, trial counting with the user token, in-flight and failed classes, follow-ups, active plans,
Docente plan, model override, update_plans validation, sales_leads and model_comparisons privacy + rate limits, funnel; also after
an upgrade from git a4f00d3 with data). opens the plans pop-up and /planes under the real headers.
Group notebook (2026-10-28): (teacher + student in the same section live, outline jump, "Agregar sección
de hoy" idempotent, generator → (handout without answers in the right section, teacher guide only as a teacher
note, plan attached, new section), into the current section, previous documents linked + imported, past-classes lock,
live room tabs on today's section / page, board page selector, admin read-only, history + restore, export, quick-room hint, Dashboard
school year, mobile; saves ) and (access matrix, sections idempotent, school-year
boundaries, lock, 8 MB cap, versions / restore permission, teacher notes, Realtime policies, board pages, legacy documents and boards;
also after an upgrade from git b0afe36 with data). The class-document suites now use a one-off class (a class of a group opens the
notebook).
tests/sql/run.sh runs the database security suites (base guarantees, hierarchy permissions, plan & usage, insights, students & calendar privacy, and the upgrade path from the previous 001) on a local
Postgres 16: PGHOST=… PGPORT=… PGUSER=postgres tests/sql/run.sh (includes csv_schedule.sql, class_insights.sql,
classdoc_attendance.sql (class document access matrix, lock, versions/restore, Realtime policies; attendance privacy, late
rule, overrides, report), classboard.sql (class whiteboard: access matrix, element validation, last-writer-wins, lock, 2 MB
cap, versions/restore, Realtime policies; also after an upgrade from git 3c0ef7b with data), consent.sql (recording consent: privacy, grant / revoke / version, minors, justifications visibility,
admin overview, Métricas, notification payload), the upgrades from git fabb1c7, c198c60, d063eb0 and 9993462 with data, and
002_live_class.sql + its RLS suite after the current 001).
CSV parser unit tests: node --no-warnings --test tests/unit-*.test.mjs.
API: node --no-warnings --test tests/api-*.test.mjs (billing, live class, recording consent in the token + admin emails, AI metadata, calendar feed + notifications).
Recording pipeline (2026-10-28): tests/api-recording.test.mjs (mocked fetch: webhook → Track Egress only for people whose consent
the DATABASE confirms, retries idempotent, egress refused / paused meanwhile, pause / resume / end authorization, SAS format, Speech
job creation + polling + transcript offsets, numbers from the timestamps, analysis JSON validation + rendering with the real view,
token cap, retention, cron secret, process authorization, vercel.json crons / rewrites), tests/sql/recording.sql (server-only
writes, consent re-check, idempotent steps, real insights replace demo ones, reads like the insights, retention; also after an upgrade
from git 08ff5fc with data + 002) and tests/e2e-recording.cjs ("Procesando tu clase…" with polling, then the real report and
student insights; still-live state; student view; pipeline off = unchanged demo flow; SHOTS=<dir> saves rec-*.png).
In-app video classroom, currently in Demo mode (simulated participants, sample insights, no cost). Classes are recorded
automatically (audio only, only people who consented — see Recording consent): an indicator "● Grabando — solo quienes dieron
su consentimiento" is always visible and the teacher can Pausar grabación / Reanudar (a pause resumes by itself after 5 minutes);
there is no REC button any more. Ending a class opened from the
Calendar (teacher / substitute) stores demo insights for that class (see Class insights). A class opened from the Calendar has a
Documento side tab (the class document) and records attendance (see Attendance). The insights renderers are shared:
public/insights-view.js / .css (live-class demo + Insights panels), panels in public/insights-panel.js.
To connect real video set LIVEKIT_URL, LIVEKIT_API_KEY and LIVEKIT_API_SECRET in Vercel.
See docs/live-class.md (activation, costs, privacy checklist). Tests: tests/e2e-live.cjs,
tests/api-live.test.mjs, tests/sql/002_live_class_rls.sql.
Qué hace (todo en el servidor; detalle en docs/live-class.md → paso 4): en una clase del calendario, cada micrófono de quien
tiene el consentimiento vigente en la base se graba solo en audio con un Track Egress de LiveKit, directo a Azure Blob
Storage (Brazil South). La pausa del docente detiene la grabación en el servidor (POST /api/class/rec); "Terminar clase" cierra la
sala para todos. Después: Azure Speech (batch, con marcas de tiempo por palabra, idioma de la clase: en-US por defecto, es-UY /
es-ES) → Azure OpenAI (informe TEC del docente + insights de cada estudiante que consintió) → Insights, sin la etiqueta "Demo".
El audio se borra a los 30 días (tarea diaria); la transcripción y los insights quedan. Las salas rápidas no se graban.
Código: api/_lib/rec/*, api/_lib/class/rec.js, api/_lib/class/process.js, api/livekit/webhook.js; base: bloque
RECORDING PIPELINE de 001_base.sql. No suma funciones de Vercel (siguen 10 de 12): todo va por api/class.js y el webhook.
Pasos del dueño (una sola vez):
recordings,
Privado. Administración del ciclo de vida → Agregar regla: prefijo recordings/, "última modificación hace más de 30 días"
→ Eliminar el blob (respaldo). Claves de acceso → copiar el nombre de la cuenta y key1.brazilsouth).https://teroclass.com/api/livekit/webhook (con la misma API key).
Revisar que el plan tenga minutos de Egress.RECORDING_ENABLED=true, AZURE_STORAGE_ACCOUNT=<nombre>,
AZURE_STORAGE_KEY=<key1>, AZURE_STORAGE_CONTAINER=recordings, AZURE_SPEECH_KEY=<CLAVE 1>, AZURE_SPEECH_REGION=brazilsouth,
CRON_SECRET=<32+ caracteres al azar>; ya tienen que estar LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET,
SUPABASE_SERVICE_ROLE_KEY, AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY. Redeploy.supabase/migrations/001_base.sql.https://teroclass.com/api/health → "recording_configured": true; Vercel → Settings → Cron Jobs muestra
/api/class/process. Clase de prueba de 5 minutos con dos cuentas que aceptaron → Insights: "Procesando tu clase…" y después el
informe real. Para apagar: RECORDING_ENABLED=false + Redeploy.docs/live-class.md (abogado, menores, contrato con LiveKit y
Microsoft).Costo por hora de clase (estimación, verificar precios): con 26 personas grabadas ≈ Speech US$ 4,7 (≈ US$ 0,18 por hora de
audio; cada micrófono es un archivo de toda la clase) + IA US$ 0,3–0,6 + Storage < US$ 0,01 → ≈ US$ 2–6 por hora de clase
según cuántos se graban, más los minutos de video y de Egress de LiveKit de su plan. Tabla completa en docs/live-class.md.
Limitations (recording). Track Egress records the whole time a microphone is in the room (silences included: Speech bills
them); a consent revoked during a class stops new recordings (next track / resume), not the one in progress; ad-hoc rooms are
not recorded; insights are produced once per class (re-entering a finished class records more audio but does not redo the
analysis); the Vercel Hobby cron runs once a day, so the class advances fastest while someone has its Insights open; the
per-call audit rows of 002 (consents) are not written yet; guardians still have no access to a minor's insights.
TURNSTILE_SECRET_KEY(optional) Cloudflare Turnstile secret, only for the sales form without signing in on /planes (verified by api/_lib/notify/lead.js; also needs TURNSTILE_SITE_KEY and SUPABASE_SERVICE_ROLE_KEY). Without it the form asks people to sign in or create a free account first. (The same secret stays in Supabase for sign-up.) |
TURNSTILE_SITE_KEY | (optional, public) Cloudflare Turnstile site key (TC-18). With it, sign-up, sign-in, the student magic link and "Forgot your password?" show the check and send its token to Supabase Auth (captchaToken). The secret (TURNSTILE_SECRET_KEY) is NOT set in Vercel: paste it in Supabase → Authentication → Bot and Abuse Protection (Turnstile). Without the site key nothing changes. |
RECORDING_ENABLED | (optional) true turns on the real recording pipeline (LiveKit Egress → Azure Blob → Azure Speech → AI insights), but only when ALL of its variables are set too: the three LIVEKIT_*, AZURE_STORAGE_*, AZURE_SPEECH_*, CRON_SECRET and SUPABASE_SERVICE_ROLE_KEY (/api/health → recording_configured). Until then the room says "Recording ready to be turned on — nothing is recorded yet" (TC-23), and with it "● Recording" appears only once the server really started recording someone. See Grabación real. |
AZURE_STORAGE_ACCOUNT, AZURE_STORAGE_KEY, AZURE_STORAGE_CONTAINER | (recording) Azure Storage account in Brazil South, its key1, and the private container recordings. Server only: LiveKit gets the key in the egress request to upload the audio; the browser never sees it. |
AZURE_SPEECH_KEY, AZURE_SPEECH_REGION | (recording) Azure AI Speech resource (Standard S0) in Brazil South: its KEY 1 and brazilsouth. Optional AZURE_SPEECH_API_VERSION (default 2024-11-15). |
CRON_SECRET | (recording) 32+ random characters (openssl rand -hex 32). Vercel sends it to the daily job /api/class/process (vercel.json → crons: processing + deleting audio older than 30 days). |
INSIGHTS_SESSION_TOKEN_CAP | (optional) AI tokens allowed per recorded class (default 150000). |
RECORDING_SPANISH_LOCALE | (optional) es-UY (default) or es-ES: the transcription language of classes whose plan says "Spanish" (others: en-US). |
APP_ENV | (optional) production on the production project. Any other value (e.g. staging) shows the thin "Versión de prueba" banner even on teroclass.com; any host other than teroclass.com / www.teroclass.com shows it anyway (P-21). |
ROOM_TICKET_SECRET | (optional) Secret for the invitation links of quick live rooms (TC-13). Default: derived from LIVEKIT_API_SECRET. |
AI_IP_HOURLY_LIMIT | (optional) AI requests per hour from one IP (default 300; a school shares one IP). |
RATE_LIMIT_SECRET | (optional) Key for the HMAC of IPs in the rate limits (default: derived from the Azure key). |
SENTRY_DSN | (optional) Browser and API errors are also sent to Sentry from the server (TC-12). |
LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET | (optional) The real video classroom (see docs/live-class.md). |
trial_statetrial_mark/planes (/plans) — public/planes.html + plans.js + plans.css: works without signing in; monthly / yearly toggle,
USD / EUR (EUR by default for Spain / EU browsers; Docente "IVA incluido", Institución "+ IVA"), the Institución tier table,
add-ons, launch offers (only when on), FAQ (students free, what a class is, classroom hours, transfer, running out, privacy).
Linked from the user menu (Planes y precios) and Plan & usage. ?lang=en, ?currency=EUR, ?contact=sales work.POST /api/notify/lead → submit_sales_lead with the person's session (validated, 3 per hour / 10 per day per person, per-IP
limit) or, without a session, only with Turnstile fully configured (submit_sales_lead_service, per-IP HMAC limits). Table
sales_leads: no direct access; only the super admin reads it (Platform → Sales requests: status nuevo / contactado /
ganado / perdido, notes, CSV export with formula-safe cells, a red badge for new ones). E-mail to SALES_NOTIFY_EMAIL when
RESEND_API_KEY exists (reply-to = the lead). The person's own requests are in Download my data.ai_model_default); /api/generate uses AZURE_OPENAI_DEPLOYMENT_MINI
for "mini" and falls back to the default deployment if it is missing. Mini prices (USD per 1M tokens, defaults 0.40 / 1.60 from
the proposal — to verify against the Azure price sheet) next to the default model's (AI cost estimate)./api/generate with model: 'default' | 'mini' — accepted only from the super admin (decided by the database:
consume_ai_quota → reason:'model' → 403 for anyone else). Random left/right order, model names hidden until rated; time,
tokens and estimated cost per side; rate A better / B better / same + note → model_comparisons (super admin only; cost
recomputed with the stored prices); summary with win rates and average cost per class per model.class_boards, so the boards that already existed are those pages, with
their drawings, 2 MB cap and versions per page); a page selector (‹ / select / › / Hoy) inside the board; the live room's
Pizarra tab opens it on today's page. Same people and read-only rules as the notebook.vercel.json; supabase-js
2.116.0, livekit-client 2.22.3, pdf.js 4.10.38 (isEvalSupported:false), mammoth 1.13.0 and Inter in public/vendor/
(exact versions). tests/e2e-csp.cjs runs the app under the real headers./privacidad and /terminos
(TC-06 / P-12, "Borrador — debe revisarlo un abogado", placeholders [RAZÓN SOCIAL], [RUT], [DIRECCIÓN], [EMAIL DE
CONTACTO]); "Versión de prueba" banner outside production (P-21)./api/health.001_base.sql; then in Platform → Institutions: plan and usage set each institution's
plan status (every existing one starts as Pilot; "No plan" puts its teachers on the 5-class trial); check prices in
Platform → Plans & pricing; switch on the launch offers you want.SALES_NOTIFY_EMAIL=<your sales inbox> (+ RESEND_API_KEY, already used for invitations).
Optional, for the form without signing in on /planes: TURNSTILE_SECRET_KEY in Vercel (same secret as in Supabase) +
TURNSTILE_SITE_KEY + SUPABASE_SERVICE_ROLE_KEY.gpt-4o deployment (the one
whose endpoint is AZURE_OPENAI_ENDPOINT).
2. Left menu → Models + endpoints (older UI: Deployments) → + Deploy model → Deploy base model.
3. Search gpt-4.1-mini, select it → Confirm.
4. Deployment name: gpt-4.1-mini (any name works; it must be exactly what you put in Vercel). Deployment type:
Global Standard (or Standard in your region if data residency matters). Model version: the latest offered.
5. Customize → Tokens per Minute Rate Limit: start with ~50K TPM (enough for the comparison; raise it if you switch kinds
to mini). Leave content filter = Default. Deploy.
6. Check the API version: the deployment page shows a sample with api-version=…; AZURE_OPENAI_API_VERSION
(2024-12-01-preview) works for gpt-4.1-mini. Same endpoint and key as gpt-4o — no new key.
7. Vercel → Settings → Environment Variables → add AZURE_OPENAI_DEPLOYMENT_MINI = gpt-4.1-mini (Production + Preview) →
Redeploy.
8. In TeroClass: Platform → the "mini model is not configured" warning disappears → Compare models → load the last 20 saved
classes (e.g. The English Coach) → compare and rate each pair. Only after deciding, switch kinds to "Mini model" in
Platform → AI model.
9. Verify the price: Azure portal → Cost Management, or the Azure OpenAI pricing page → gpt-4.1-mini input / output per 1M
tokens → update Platform → AI model → Mini model price. Add the new deployment to the budget alert.SHOTS=<dir>pay-*.pngtests/api-plans.test.mjstrial_limittests/sql/plans.sqltests/e2e-csp.cjstests/e2e-notebook.cjsSHOTS=<dir>nb-*.pngtests/sql/notebook.sql