English Mithra · Trainers · database as of 14 Oct 2026

EM Trainers Database

Every table the EM Trainers system has, what each column stores, how the tables connect, and how a lesson moves from the database to a student's WhatsApp. Database emtcc on the server's Postgres, plus lesson files in MinIO. Checked read-only.

Lesson-sending number
No lesson-sending WhatsApp number is set up yet.

Every place it could be stored was checked: the number table em_wa_numbers has 0 rows, the "Lessons Bot" chat section has a blank number and Phone number ID, and the old July system has 0 WhatsApp accounts. Only a template name is filled in (em_day_test, language ml), and that was left by the automated test, so it must be replaced with the real approved template.

Where it will live once added (Admin → EM Setup → WhatsApp numbers): one row in em_wa_numbers with role bot. The token is stored encrypted. The engine then copies the number into the "Lessons Bot" chat section automatically.

What the business must provide: the WhatsApp number (91…), its Phone number ID, the WABA ID, a permanent access token, and an approved template with a quick-reply Start button and no variables. In Meta, the webhook must point to /api/engine/meta-webhook (verify token shown on EM Setup), subscribed to messages, plus message echoes for trainer numbers. Each trainer's own WhatsApp Business number is added the same way with role trainer.

Where data is stored

Three stores

emtcc database

Postgres on the shared server (supabase-db). 61 tables in three sections: public (students, trainers, lessons, settings) and two chat sections with EP's generated names: p4boly0zb0p8fp1 = lessons-bot chats, py7a0z2w9agcvv2 = trainer-phone chats. 12 MB.

MinIO emtrainers-media

File storage. em_DAY_ORDER_TYPE lesson files (338: 328 voice notes, 10 images), chat-media/ for chat photos and voice notes (created on first message), and the old lessons/ originals.

emtrainers database

The old July system. Its 736 lesson messages were copied into Lesson_Resources with the same ids; its student and log tables were empty. Not used by the new system.

Diagram 1

How everything connects

flowchart TB
  subgraph SETUP["Admin → EM Setup"]
    N["em_wa_numbers<br/>bot + trainer numbers<br/>(token encrypted)"]
    S["app_settings<br/>em_* settings"]
    TR["Trainers_info"]
    EN["Enrol students"]
  end
  EN --> SD["Student_Details<br/>name, mobile, class mobile"]
  SD --> SPg["Student_Progress<br/>day + state"]
  SPg <--> J1["t_ovaj35d9wum<br/>weekday trainers"]
  SPg <--> J2["t_71p261vuowi<br/>Sunday trainers"]
  J1 --> TR
  J2 --> TR
  N -. "copied to" .-> CFG["projects.config.json<br/>Lessons Bot section"]
  CRON(["Daily 08:00 job"]) --> SPg
  CRON -- "template via bot" --> META{{"WhatsApp<br/>Cloud API"}}
  META -- "Start tap / replies / statuses" --> WH(["Webhook<br/>/api/engine/meta-webhook"])
  WH --> CH1["Chat History<br/>p4boly0zb0p8fp1<br/>(lessons bot)"]
  WH --> CH2["Chat History<br/>py7a0z2w9agcvv2<br/>(trainer phones)"]
  WH --> SPg
  LR["Lesson_Resources<br/>54 days · 736 parts"] --> SEND(["Send day's lesson"])
  MINIO[("MinIO emtrainers-media<br/>em_DAY_ORDER_TYPE files")] --> SEND
  SEND --> META
  SEND --> MST["Media_Status_Trainer<br/>receipt per part"]
  SEND --> CH1
  CRON --> LOG["Student_Day_logs<br/>events"]
  WH --> LOG
  H["holidays"] --> CRON
  S --> CRON
  OV["trainer_support_overrides"] --> SEND
  TR --> SEND
  CH1 & CH2 & SPg & LOG --> DASH(["TCC dashboards"])

Boxes are tables, rounded boxes are running jobs, the hexagon is WhatsApp. Arrows show which way data moves.

Diagram 2

Table relationships

erDiagram
  Course_Details ||--o{ Student_Details : "course_id"
  Course_Details ||--o{ Lesson_Resources : "course_id"
  Student_Details ||--o{ Student_Progress : "student_id"
  Student_Progress ||--o{ t_ovaj35d9wum : "student_id1"
  Trainers_info ||--o{ t_ovaj35d9wum : "student_id2"
  Student_Progress ||--o{ t_71p261vuowi : "s_student_id"
  Trainers_info ||--o{ t_71p261vuowi : "sunday_trainer_id"
  Student_Details ||--o{ Student_Day_logs : "student_id"
  Trainers_info ||--o{ Student_Day_logs : "trainer_id"
  Lesson_Resources ||--o| lesson_media_sync : "lesson_id"
  Student_Progress ||--o{ trainer_followups : "student_progress_id"
  Course_Details { bigint id PK
    varchar Course_Name
    varchar Region }
  Student_Details { bigint id PK
    varchar Student_Name
    varchar Mobile_Number
    varchar Class_Mobile_Number
    bigint course_id FK
    date Batch_Date }
  Student_Progress { bigint id PK
    bigint student_id FK
    varchar current_lesson_day
    varchar Progress_state
    int Follow_up_count
    date Next_Followup_At
    bool is_stop
    date Next_Start_Date }
  Trainers_info { bigint id PK
    varchar full_name
    varchar Trainer_WA_Number
    varchar Shift
    jsonb Day
    varchar Status }
  t_ovaj35d9wum { bigint student_id1 FK
    bigint student_id2 FK }
  t_71p261vuowi { bigint s_student_id FK
    bigint sunday_trainer_id FK }
  Lesson_Resources { bigint id PK
    bigint course_id FK
    int Day
    int Send_Order
    varchar Resource_type
    text Text_Message
    text File_Key
    text Media_ID
    date Media_Expiry_Date }
  Student_Day_logs { bigint id PK
    bigint student_id FK
    bigint trainer_id FK
    varchar lesson_day
    varchar event_type }
Linked by phone number (last 10 digits)How
Student → lesson-bot chatsStudent_Details.Class_Mobile_Number (or Mobile_Number) = p4boly0zb0p8fp1."Chat History" From/To_Phone_Number
Trainer → trainer chatsTrainers_info.Trainer_WA_Number = py7a0z2w9agcvv2."Chat History".Phone_Number
Number registry → chatsem_wa_numbers.phone_number_id = Chat History.Phone_Number_ID (role bot → bot chats, role trainer → trainer chats)
Student → lesson receiptsstudent's number = Media_Status_Trainer.WA_NUMBER (no student id stored)
Trainer → substitutionsTrainers_info.Trainer_WA_Number = trainer_support_overrides.main_wa

The links are kept by the code, not enforced by the database (EP's design). A student's trainers are not a column on the student: they are rows in the two junction tables, one row per trainer. t_ovaj35d9wum holds the weekday trainers and t_71p261vuowi the Sunday trainers; both point at Student_Progress.id, not Student_Details.id. Chats link to students by phone number (last 10 digits).

Diagram 3

One lesson day, step by step

sequenceDiagram
  autonumber
  participant J as Daily job (08:00 IST)
  participant DB as Database (emtcc)
  participant WA as WhatsApp Cloud API
  participant S as Student
  participant T as Trainer phone
  J->>DB: Read Student_Progress (Ready / Waiting_completed, has weekday trainer)
  J->>WA: Day template with Start button (from bot number)
  J->>DB: State = Waiting_start · Next_Followup_At = tomorrow · log waiting_start · Chat History row
  WA->>S: Template arrives
  S->>WA: Taps Start
  WA->>DB: Webhook → Chat History (incoming Button)
  DB->>DB: Claim: state = Waiting_completed · log waiting_completed
  loop each Lesson_Resources part (Send_Order)
    DB->>WA: Text / voice note / image (media from MinIO, Media_ID cached 29 days)
    WA-->>DB: Status "sent" → next part
  end
  DB->>WA: Trainer footer (Morning / Evening / Night names + numbers)
  WA->>S: Lesson + footer
  S->>T: Homework on trainer's own WhatsApp
  T-->>DB: Coexistence webhook → trainer Chat History
  Note over J,DB: Next morning: Waiting_completed → day + 1 → new template
Diagram 4

Student state cycle

stateDiagram-v2
  [*] --> Ready: Enrolled
  Ready --> Waiting_start: 8 AM template sent
  Waiting_start --> Waiting_completed: Start tap, lesson sent
  Waiting_start --> follow_up_sent: No tap, reminder 1
  follow_up_sent --> follow_up_sent: Reminders 2 to 5
  follow_up_sent --> Waiting_completed: Start tap, lesson sent
  Waiting_completed --> Waiting_start: Next morning, next day
  Waiting_completed --> [*]: Day 54 done
  Ready --> Paused: Pause date set
  Paused --> Ready: Pause date reached
WhenWhat happens
Day DTemplate sent, state Waiting_start, next reminder date D+1
D+1, D+2, D+3, D+7, D+12Reminders 1–5: same template again, state follow_up_sent
D+19Reminder date cleared; no more reminders until the student replies
Rest day / holidayNo sends; a due reminder goes out on the next working day

Students the dashboard puts into other states (Paused, Stuck, Dropped, Completed…) leave this cycle until set back to Ready. Paused with a date (Next_Start_Date) returns to Ready on that date automatically.

Old → new

How the July lessons were moved

Old system (emtrainers)New Lesson_Resources
lesson_courses.id = 1course_id = 1 → Course_Details "EM Trainer Basic", KERALA
lesson_day_content.day_noDay (1–54)
lesson_day_messages.send_orderSend_Order
resource_type text / audio / imageResource_type Text / Audio / Image
text_messageText_Message
media_path lessons/1/day01/03.oggFile_Key em_1_3_Audio.ogg (file copied in MinIO, list in em_media_import)
meta_media_id (empty)Media_ID + Media_Expiry_Date (filled on first send, reused 29 days)
—Lesson_Type = Grammar
All 61 tables

Every table, every column

Open a table to see its columns, what it connects to, and which part of the code writes and reads it. Core the lesson system needs it · Support used by screens or app internals · EP-only came with EP's code and is unused on EM. Row counts are today's; most tables are empty because no students, trainers or numbers are set up yet.

Students, trainers, courses & progress · 12 tables

Course_DetailsCore · lesson system1 rowsList of courses. EM has one: id 1 'EM Trainer Basic' (region KERALA). The engine uses the course id (setting em_course_id = 1) to find the lesson content in Lesson_Resources and to know the last lesson day.
ColumnTypeWhat it stores
idbigintCourse id (primary key)
createdAttimestamptzWhen created
updatedAttimestamptzLast change
createdByIdbigintNocoBase user (EP leftover)
updatedByIdbigintNocoBase user (EP leftover)
Course_NamevarcharCourse name, e.g. 'EM Trainer Basic'
Course_PricenumericPrice (empty for EM)
RegionvarcharRegion, 'KERALA'

Connected to

  • Student_Details — Student_Details.course_id -> Course_Details.id
  • Lesson_Resources — Lesson_Resources.course_id -> Course_Details.id (MAX(Day) = number of lesson days)

Written by

  • No app code writes it; row created by the EM setup/seed (not verified which script)

Read by: em-engine/admin.js (course list with day count), routes/trainer-dashboard.js, routes/trainer-app.js, routes/lesson-resources.js, routes/messages.js, routes/student-journey.js, routes/bda-app.js, routes/dashboard.js, routes/finished-combined.js, routes/silent-students.js, routes/speaking-mark.js, routes/call-report.js, routes/bulk-message.js, routes/rejoin.js … and 3 more

Primary key: id. Only table in this group with data: 1 row (id 1, 'EM Trainer Basic', price empty, KERALA).

Student_DetailsCore · lesson system0 rowsOne row per enrolled student: who they are and which WhatsApp number gets the lessons. The lesson engine sends to Class_Mobile_Number (or Mobile_Number if that is empty), which is how a student's phone is matched when they tap Start.
ColumnTypeWhat it stores
idbigintStudent id (primary key, auto number)
createdAttimestamptzWhen the row was created
updatedAttimestamptzWhen the row last changed
createdByIdbigintNocoBase user who created it (EP leftover; EM leaves it empty)
updatedByIdbigintNocoBase user who last changed it (EP leftover)
Student_NamevarcharStudent's name
Mobile_NumbervarcharStudent's main phone number
Class_Mobile_NumbervarcharPhone that gets the class messages on WhatsApp. EM enrolment sets it to Mobile_Number when none is given. The engine matches on its last 10 digits.
Email_IDvarcharEmail (optional, EP sales field)
GendervarcharGender (EP sales field)
AgevarcharAge (EP sales field)
DistrictvarcharDistrict (EP sales field)
Current_LocationvarcharWhere the student lives now (EP sales field)
ProfessionvarcharJob (EP sales field)
Where_did_you_find_English_PartnervarcharLead source (the name is from EP's brand English Partner)
Reason_for_joining_coursetextWhy the student joined
Enquiry_DatedateDate of first enquiry
Batch_DatedateBatch start date. EM enrolment sets it to the enrolment day.
Admission_NumbervarcharAdmission or receipt number (EP sales field)
course_idbigintWhich course the student is on. Points to Course_Details.id. EM uses course 1.
counselor_idbigintThe salesperson (BDA) who sold the course. Points to Counsellor_Details.id. EM enrolment leaves it empty.

Connected to

  • Course_Details — Student_Details.course_id -> Course_Details.id
  • Counsellor_Details — Student_Details.counselor_id -> Counsellor_Details.id
  • Student_Progress — Student_Progress.student_id -> Student_Details.id
  • Speaking_Student_Progress — Speaking_Student_Progress.student_id -> Student_Details.id
  • Sales_Update_Log — trigger trg_log_student_updates writes one row per changed field (name, mobile, email, gender, district, ...)

Written by

  • backend/em-engine/admin.js (EM Setup enrolment: INSERT name, mobile, class mobile, course_id, Batch_Date=today)
  • backend/routes/rejoin.js (rejoin/re-enrol: INSERT, UPDATE Class_Mobile_Number, course_id and other fields)
  • DB trigger trg_log_student_updates (copies every edit into Sales_Update_Log, does not change this table)

Read by: em-engine/lesson.js, em-engine/cron.js, em-engine/hooks.js, em-engine/admin.js, auth.js, middleware/verify-student-owner.js, middleware/verify-speaking-owner.js, routes/trainer-dashboard.js, routes/trainer-app.js, routes/messages.js, routes/student-journey.js, routes/silent-students.js, routes/bda-app.js, routes/dashboard.js … and 9 more

Primary key: id. No unique key on phone. Duplicate check is done in code (admin.js enrolOne): same last-10-digit class/main mobile on the same course_id is refused. Indexes cover Mobile_Number, Class_Mobile_Number (plain and normalised '91'+last 10 digits), Batch_Date, course_id, counselor_id. Has an UPDATE trigger trg_log_student_updates -> Sales_Update_Log. Currently empty (0 rows): no EM students enrolled yet. Many of the columns are EP sales-form fields that EM enrolment does not fill.

Student_ProgressCore · lesson system0 rowsThe heart of the EM lesson engine: one row per student on the Grammar (EM) course, holding which lesson day they are on and where they are in today's cycle. The daily 08:00 job, the Start tap and the dashboards all read and change this row. Its id (not Student_Details.id) is what the trainer junction tables point to.
ColumnTypeWhat it stores
idbigintProgress id (primary key). Trainer links in t_ovaj35d9wum and t_71p261vuowi point to this id.
createdAttimestamptzWhen the student was enrolled
updatedAttimestamptzLast change (each send, tap or edit)
createdByIdbigintNocoBase user (EP leftover)
updatedByIdbigintNocoBase user (EP leftover)
student_idbigintWhich student. Points to Student_Details.id.
trainer_idbigintOld single-trainer column from EP. EM never fills it (always empty); trainers come from the junction tables. A few EP screens still read it (owner check, MCP trainer counts), so those show nothing on EM.
current_lesson_dayvarcharLesson day number stored as text, e.g. '1'...'54'. Enrolment sets the start day (default 1). The daily job only picks rows where it is all digits.
Progress_statevarcharWhere the student is today: Ready (gets today's template), Waiting_start (template sent, waiting for the Start tap), follow_up_sent (reminder sent, still no tap), Waiting_completed (tapped, lesson delivered; tomorrow moves to the next day). The dashboard "send-class" editor can also set Active, Completed, Dropped, Paused, Follow Up, On Track, Ahead, Behind or Stuck; any of those takes the student out of the engine's daily cycle until set back to Ready.
Follow_up_countintegerHow many reminders have gone out for the current day (0-5). Reset to 0 on a new day or a tap.
Next_Followup_AtdateDate the next reminder is due if there is still no tap. Cleared on tap or after the 5th reminder.
is_stopbooleanTrue = student stopped; the engine sends nothing.
Next_Start_DatedatePause until this date. On that day the daily job clears it and sets the student back to Ready.

Connected to

  • Student_Details — Student_Progress.student_id -> Student_Details.id
  • Trainers_info — t_ovaj35d9wum.student_id1 = Student_Progress.id, student_id2 = Trainers_info.id (Mon-Sat trainers)
  • Trainers_info — t_71p261vuowi.s_student_id = Student_Progress.id, sunday_trainer_id = Trainers_info.id (Sunday trainers)
  • trainer_followups — trainer_followups.student_progress_id -> Student_Progress.id
  • Student_Day_logs — lesson.js logDay writes Student_Day_logs.student_id = Student_Progress.student_id (the Student_Details id) for every state change

Written by

  • backend/em-engine/admin.js (enrolment INSERT: state Ready, Follow_up_count 0, is_stop false, start day)
  • backend/em-engine/cron.js (daily dispatch: pause end -> Ready; template -> Waiting_start; reminders -> follow_up_sent)
  • backend/em-engine/lesson.js (Start tap -> Waiting_completed)
  • backend/em-engine/hooks.js (dashboard 'Send Today's Class' -> Waiting_start)
  • backend/routes/trainer-dashboard.js (manual edit of day, state, follow-up date, stop, pause)
  • backend/routes/rejoin.js (rejoin resets progress)
  • backend/scripts/enrol-mastery-students.js, backend/backfill-speaking.js, backend/scripts/shift-mastery-speaking-to-day1.js (EP one-off scripts)

Read by: em-engine/cron.js, em-engine/lesson.js, em-engine/hooks.js, em-engine/admin.js, middleware/verify-student-owner.js, routes/trainer-dashboard.js, routes/trainer-app.js, routes/messages.js, routes/student-journey.js, routes/silent-students.js, routes/student-trainers.js, routes/finished-combined.js, routes/bulk-message.js, routes/ai-analysis.js … and 5 more

Primary key: id. No unique constraint on student_id. Indexes: student_id, trainer_id, Progress_state, Next_Followup_At (partial, not null). A student's day, step by step (settings: em_send_time 08:00 IST, em_rest_days Sunday, em_lesson_type Grammar, em_course_id 1, engine enabled): (1) Enrolment (EM Setup) inserts Student_Details + this row with Progress_state='Ready', current_lesson_day=start day, and links trainers in t_ovaj35d9wum / t_71p261vuowi. (2) Every day at/after 08:00 IST (once per day, claimed via app_settings em_last_dispatch_date), unless Sunday or a holidays row with skip_grammar, cron.js: a) rows whose Next_Start_Date <= today and not stopped -> Next_Start_Date NULL, state Ready; b) rows in Waiting_start/follow_up_sent whose Next_Followup_At <= today -> the day template is sent again, state follow_up_sent, Follow_up_count+1, Next_Followup_At = today + 1/1/4/5/7 days for reminders 1-5; after the 5th, Next_Followup_At is cleared and reminders stop; c) rows in Ready or Waiting_completed -> Waiting_completed first moves current_lesson_day +1 (skipped if already at the last day = finished), the WhatsApp day template with a Start button is sent from the bot number, then state Waiting_start, Follow_up_count 0, Next_Followup_At tomorrow. Only students linked to at least one weekday trainer in t_ovaj35d9wum, not stopped and not paused are picked. (3) When the student taps Start (or sends any message, because em_start_on_any_message=true) on the bot number, webhook.js -> lesson.js handleStartTap matches the last 10 digits of Class_Mobile_Number/Mobile_Number, sets state Waiting_completed, clears Next_Followup_At and Follow_up_count, writes a Student_Day_logs row, then sends that day's Lesson_Resources in Send_Order plus the trainer footer (trainer names and numbers from the junction tables, with trainer_support_overrides applied) and, on the last day, the completion message. (4) The next morning the cycle repeats with day+1. Each state change also writes a Student_Day_logs row (waiting_start, follow_up_sent, waiting_completed). Currently empty.

Trainers_infoCore · lesson system0 rowsList of trainers: name, WhatsApp number, shift and working days. Students are linked to trainers through the junction tables; the trainer's name and number are written into the footer sent after each lesson, and the trainer number is how the trainer logs into and is matched in the trainer app.
ColumnTypeWhat it stores
idbigintTrainer id (primary key)
createdAttimestamptzWhen added
updatedAttimestamptzLast change
createdByIdbigintNocoBase user (EP leftover)
updatedByIdbigintNocoBase user (EP leftover)
full_namevarcharTrainer's name (shown to students in the lesson footer)
Trainer_WA_NumbervarcharTrainer's own WhatsApp number. Students are told to contact this number; it is not the lesson-sending number (lessons go from the bot number in em_wa_numbers).
RegionvarcharRegion; EM always writes 'KERALA'
ShiftvarcharMorning, Evening or Night. Enrolment picks one trainer per shift; the footer lists them in that order.
DayjsonbList of weekdays. On EM it only decides Sunday-pool membership (contains "Sunday" = can be a Sunday trainer). EM Setup defaults to all 7 days. (In EP it meant the student cohort start day.)
Lesson_TypevarcharCourse type; EM always writes 'Grammar'
EMP_IDvarcharEmployee id (optional)
StatusvarcharActive, or inactive/disabled/left/resigned/blocked. Inactive trainers are not picked for new students.

Connected to

  • Student_Progress — t_ovaj35d9wum.student_id2 = Trainers_info.id (weekday)
  • Student_Progress — t_71p261vuowi.sunday_trainer_id = Trainers_info.id (Sunday)
  • Speaking_Student_Progress — t_tu17p340gxo.f_21qx09ebh45 / t_q4786kg69kh.Trainer_id = Trainers_info.id (EP speaking)
  • trainer_support_overrides — trainer_support_overrides.main_wa matched to Trainers_info.Trainer_WA_Number by last 10 digits
  • trainer_followups — trainer_followups.trainer_id -> Trainers_info.id

Written by

  • backend/em-engine/admin.js (EM Setup: add/edit trainer)
  • backend/scripts/rename-sunday-team-b.js, backend/scripts/mastery-trainer-rows.js (EP one-off scripts)

Read by: em-engine/lesson.js (footer), em-engine/admin.js (auto-assign least-loaded trainer per shift), auth.js, middleware/verify-student-owner.js, routes/trainer-app.js, routes/trainer-dashboard.js, routes/trainer-auth-admin.js, routes/trainer-substitutes.js, routes/shift.js, routes/messages.js, routes/silent-students.js, routes/app-usage.js, routes/filters.js, routes/student-trainers.js … and 4 more

Primary key: id. No unique key on Trainer_WA_Number (index idx_trainers_info_wa only). Currently empty: no EM trainers added yet, so enrolment would fail with 'No active trainers to assign'.

holidaysCore · lesson system0 rowsDays off. If a date is listed with skip_grammar = true, the EM daily lesson job sends nothing that day (no templates, no reminders). The other skip flags are for EP courses.
ColumnTypeWhat it stores
idintegerHoliday id (primary key)
holiday_datedateThe day off (unique)
reasontextWhy, e.g. festival name
skip_speakingbooleanSkip EP Speaking sends (EP only)
skip_grammarbooleanSkip Grammar sends: the one EM checks
skip_kannadabooleanSkip EP Kannada course (EP only)
skip_tamilbooleanSkip EP Tamil course (EP only)
skip_telugubooleanSkip EP Telugu course (EP only)
created_attimestamptzWhen added

Connected to

  • None

Written by

  • backend/routes/holidays.js (admin add/edit/delete) via frontend/src/components/HolidaysPanel.jsx

Read by: em-engine/cron.js, routes/holidays.js, routes/student-journey.js, routes/finished-combined.js, routes/admin-mcp-tools.js, mcp-connector/tools.js, frontend/src/components/trainer-dashboard/buildCombinedReport.js

Primary key: id. Unique: holiday_date (one row per date). All skip flags default true. Empty. Sundays are skipped separately by the setting em_rest_days='Sunday', not by this table.

t_71p261vuowiCore · lesson system0 rowsJunction table (NocoBase name) linking a student's Grammar progress row to their Sunday trainers. Used for the lesson footer when the lesson goes out on a Sunday. Note: EM's rest day is Sunday, so the daily job sends nothing then, but a lesson sent manually on Sunday would use these trainers.
ColumnTypeWhat it stores
createdAttimestamptzWhen the link was made
updatedAttimestamptzLast change
s_student_idbigintStudent_Progress.id
sunday_trainer_idbigintTrainers_info.id of a Sunday trainer

Connected to

  • Student_Progress — t_71p261vuowi.s_student_id -> Student_Progress.id
  • Trainers_info — t_71p261vuowi.sunday_trainer_id -> Trainers_info.id

Written by

  • backend/em-engine/admin.js (enrolment: least-loaded trainer per shift among those whose Day includes Sunday)
  • backend/scripts/enrol-mastery-students.js, backend/scripts/mastery-trainer-sync.js, backend/scripts/retire-sunday-team-b.js, backend/scripts/undo-sunday-team-b.js (EP scripts)

Read by: em-engine/lesson.js (Sunday footer), em-engine/admin.js (Sunday student count), routes/trainer-app.js, routes/trainer-dashboard.js, routes/messages.js, routes/silent-students.js, routes/student-trainers.js, routes/shift.js, routes/app-usage.js, routes/trainer-substitutes.js, routes/rejoin.js, mcp-connector/tools.js

Primary key: (s_student_id, sunday_trainer_id). Extra index on sunday_trainer_id. No real FKs. Empty.

t_ovaj35d9wumCore · lesson system0 rowsJunction table (cryptic name made by EP's NocoBase) linking a student's Grammar progress row to their Monday-Saturday trainers, usually one per shift. A student with no row here is never sent a lesson by the daily job.
ColumnTypeWhat it stores
createdAttimestamptzWhen the link was made
updatedAttimestamptzLast change
student_id1bigintStudent_Progress.id (despite the name, it is the progress row, not Student_Details)
student_id2bigintTrainers_info.id (despite the name, it is the trainer)

Connected to

  • Student_Progress — t_ovaj35d9wum.student_id1 -> Student_Progress.id
  • Trainers_info — t_ovaj35d9wum.student_id2 -> Trainers_info.id

Written by

  • backend/em-engine/admin.js (enrolment: chosen trainers or least-loaded active trainer per shift; ON CONFLICT DO NOTHING)
  • backend/scripts/enrol-mastery-students.js, backend/scripts/mastery-trainer-sync.js, backend/scripts/patch-mastery-enrolment.js (EP scripts)
  • backend/routes/rejoin.js (deletes/relinks on rejoin; not verified in detail)

Read by: em-engine/cron.js (LINKED guard), em-engine/lesson.js (weekday footer trainers, trainer_id for Student_Day_logs), em-engine/admin.js (student count per trainer), middleware/verify-student-owner.js (trainer may only see own students), routes/trainer-app.js, routes/trainer-dashboard.js, routes/trainer-head-app.js, routes/messages.js, routes/silent-students.js, routes/student-trainers.js, routes/shift.js, routes/app-usage.js, routes/trainer-substitutes.js, routes/bulk-message.js … and 1 more

Primary key: (student_id1, student_id2), so the same trainer cannot be linked twice. Extra index on student_id2. No real FKs. Empty.

trainer_followupsSupport · screens & internals0 rowsA trainer's own to-do list in the trainer app: 'call/check this student on this date', then marked done with an outcome. It does not change the automatic WhatsApp reminders (those use Student_Progress.Next_Followup_At).
ColumnTypeWhat it stores
idintegerFollow-up id (primary key)
student_progress_idintegerStudent_Progress.id of the student
trainer_idintegerTrainers_info.id of the trainer who owns the task
scheduled_datedateDay to do it
typevarcharKind of follow-up, default 'check_in'
notestextTrainer's note when scheduling
statusvarcharupcoming / done / other states (default 'upcoming')
outcomevarcharResult recorded when completed
completed_attimestamptzWhen marked done
completed_notestextNotes on completion
created_attimestamptzWhen created
updated_attimestamptzLast change

Connected to

  • Student_Progress — trainer_followups.student_progress_id -> Student_Progress.id
  • Trainers_info — trainer_followups.trainer_id -> Trainers_info.id

Written by

  • backend/routes/trainer-app.js (creates the table if missing; INSERT, UPDATE status/complete)

Read by: routes/trainer-app.js

Primary key: id. Indexes on trainer_id, student_progress_id, scheduled_date. No FKs. Empty.

trainer_support_overridesSupport · screens & internals0 rowsTemporary trainer swaps (Trainer Support): between start_date and end_date, wherever a trainer's number would appear in the lesson footer, a substitute's name and number are shown instead, e.g. when a trainer is on leave.
ColumnTypeWhat it stores
idintegerOverride id (primary key)
main_watextWhatsApp number of the trainer being covered (matched by last 10 digits)
main_nametextName of the trainer being covered
override_nametextSubstitute trainer's name shown to students
override_watextSubstitute's WhatsApp number shown to students
start_datedateFirst day the swap applies
end_datedateLast day the swap applies
created_bytextDashboard user who set it up
created_attimestamptzWhen created
cancelled_attimestamptzWhen cancelled early (NULL = active)
cancelled_bytextWho cancelled it

Connected to

  • Trainers_info — main_wa matched to Trainers_info.Trainer_WA_Number by last 10 digits (no id stored)

Written by

  • backend/routes/trainer-substitutes.js (creates the table if missing; INSERT, UPDATE, cancel) via frontend/src/components/TrainerSupportPanel.jsx

Read by: em-engine/lesson.js (buildFooter: active rows where cancelled_at IS NULL and today is between start and end), routes/trainer-substitutes.js, n8n-footer-wiring.js

Primary key: id. No unique constraint (overlapping swaps possible; the first match wins in lesson.js). Empty.

Speaking_Student_ProgressEP-only · unused on EM0 rowsEP's progress row for the separate Speaking course (same idea as Student_Progress). EM teaches Grammar only, so the EM engine never creates or reads it; trainer screens copied from EP still query it and find nothing.
ColumnTypeWhat it stores
idbigintSpeaking progress id (primary key)
createdAttimestamptzWhen created
updatedAttimestamptzLast change
createdByIdbigintNocoBase user (EP leftover)
updatedByIdbigintNocoBase user (EP leftover)
student_idbigintStudent_Details.id
current_lesson_dayvarcharSpeaking lesson day
Progress_statevarcharSpeaking state (same values as Student_Progress)
Follow_up_countintegerReminders sent
Next_Followup_AtdateNext reminder date
is_stopbooleanStopped
Next_Start_DatedatePause until

Connected to

  • Student_Details — Speaking_Student_Progress.student_id -> Student_Details.id
  • Trainers_info — t_tu17p340gxo / t_q4786kg69kh junctions

Written by

  • backend/routes/speaking-mark.js (trainer marks speaking done)
  • backend/routes/trainer-dashboard.js (edit/stop, shared code path; not verified)
  • backend/routes/rejoin.js
  • backend/backfill-speaking.js, backend/scripts/enrol-mastery-students.js, backend/scripts/shift-mastery-speaking-to-day1.js (EP scripts)
  • EP n8n workflows in backend/n8n (not running on EM)

Read by: routes/trainer-app.js, routes/trainer-dashboard.js, routes/messages.js, routes/student-journey.js, routes/silent-students.js, routes/student-trainers.js, routes/finished-combined.js, middleware/verify-speaking-owner.js, utils/stop-both.js, mcp-connector/tools.js, frontend/src/components/trainer-dashboard/buildCombinedReport.js

Primary key: id. Unique index uq_speaking_progress_student_id on student_id (one speaking row per student). Empty.

t_q4786kg69khEP-only · unused on EM0 rowsEP junction table linking a Speaking-course progress row to its Sunday trainers. Not used by EM.
ColumnTypeWhat it stores
createdAttimestamptzWhen the link was made
updatedAttimestamptzLast change
Student_idbigintSpeaking_Student_Progress.id
Trainer_idbigintTrainers_info.id (Sunday trainer)

Connected to

  • Speaking_Student_Progress — t_q4786kg69kh.Student_id -> Speaking_Student_Progress.id
  • Trainers_info — t_q4786kg69kh.Trainer_id -> Trainers_info.id

Written by

  • backend/backfill-speaking.js
  • backend/scripts/enrol-mastery-students.js
  • backend/scripts/mastery-trainer-sync.js (EP scripts)
  • backend/routes/rejoin.js (deletes on rejoin)

Read by: routes/rejoin.js, scripts/check-mastery-trainerless.js, scripts/unassign-mastery-trainers.js

Primary key: (Student_id, Trainer_id). Extra index on Trainer_id. Empty.

t_tu17p340gxoEP-only · unused on EM0 rowsEP junction table (NocoBase name) linking a Speaking-course progress row to its weekday trainers. EM has no Speaking course, so nothing on EM fills it, but many trainer screens still look it up (and get nothing).
ColumnTypeWhat it stores
createdAttimestamptzWhen the link was made
updatedAttimestamptzLast change
f_gqgr9qm4w4sbigintSpeaking_Student_Progress.id
f_21qx09ebh45bigintTrainers_info.id

Connected to

  • Speaking_Student_Progress — t_tu17p340gxo.f_gqgr9qm4w4s -> Speaking_Student_Progress.id
  • Trainers_info — t_tu17p340gxo.f_21qx09ebh45 -> Trainers_info.id

Written by

  • backend/backfill-speaking.js
  • backend/scripts/enrol-mastery-students.js
  • backend/scripts/mastery-trainer-sync.js
  • backend/scripts/patch-mastery-enrolment.js (all EP scripts; not used on EM)

Read by: routes/trainer-app.js, routes/speaking-mark.js, routes/messages.js, routes/shift.js, routes/app-usage.js, routes/trainer-substitutes.js, routes/student-trainers.js, routes/rejoin.js, middleware/verify-speaking-owner.js, mcp-connector/tools.js

Primary key: (f_gqgr9qm4w4s, f_21qx09ebh45). Extra index on f_21qx09ebh45. Its ids are Speaking progress ids, a separate number range from Student_Progress ids that can overlap numerically (rejoin.js warns about this). Empty.

Lessons, sending & WhatsApp numbers · 9 tables

Lesson_ResourcesCore · lesson system736 rowsThe lesson book. Each row is ONE message (a text, an audio clip or an image) that is sent to a student on a given lesson day, in a given order. The lesson engine reads all rows for (course, lesson type, day) and sends them one after another.
ColumnTypeWhat it stores
idbigint (serial)Row number of this lesson message. Also used in the chat media link /download/em/lesson/<id>.
createdAttimestamptzWhen the row was created.
updatedAttimestamptzWhen the row was last changed (also bumped when a new WhatsApp Media_ID is cached).
createdByIdbigintNocoBase-era 'created by user' id; not used by EM (empty).
updatedByIdbigintNocoBase-era 'updated by user' id; not used by EM.
course_idbigintWhich course this message belongs to (Course_Details.id). All 736 rows are course 1.
DayintegerLesson day number (1 to 54 today). MAX(Day) per course is treated as the course's last day.
Send_OrderintegerPosition of this message within the day (1 = sent first).
Lesson_TypevarcharKind of lesson: all EM rows are 'Grammar' (EP also had Speaking/Kannada/Tamil/Telugu999).
Resource_typevarcharText, Audio, Image (Video/Document also supported). Today: 398 Text, 328 Audio, 10 Image.
Text_MessagetextThe words to send. For a Text row it is the message; for an Audio row it is sent as a separate text just before the audio; for Image/Video it becomes the caption.
Drive_LinktextEP-era Google Drive link to the source file; empty in EM (0 rows).
File_KeytextName of the media file in MinIO storage (bucket emtrainers-media), e.g. em_1_3_Audio.ogg. 338 rows have one.
Media_IDtextWhatsApp's id for the uploaded file, cached so the file does not have to be uploaded again each send. Empty in all rows today (nothing uploaded to WhatsApp yet).
Media_Expiry_DatedateDate the cached Media_ID stops being trusted (set to today + 29 days on upload). After it the engine re-uploads from MinIO.

Connected to

  • Course_Details — Lesson_Resources.course_id -> Course_Details.id
  • lesson_media_sync — lesson_media_sync.lesson_id -> Lesson_Resources.id
  • em_media_import — Lesson_Resources.File_Key = em_media_import.file_key (all 338 match)
  • Student_Progress — Student_Progress.current_lesson_day (text) matched to Lesson_Resources.Day for the student's course
  • Media_Status_Trainer — Media_Status_Trainer (course_id, Lesson_Type, Day, Order) records each send of a Lesson_Resources row (Order = position in the day, not the row id)

Written by

  • backend/routes/lesson-resources.js (Admin 'Lesson Resources' tab: create/edit/delete rows, replace media file)
  • backend/em-engine/lesson.js ensureMediaId (writes Media_ID + Media_Expiry_Date after uploading to WhatsApp)
  • backend/em-engine/hooks.js lesson-media-sync hook (via lesson.ensureMediaId)
  • backend/scripts/import-lesson-audio.js, clone-mastery-lessons.js, fix-* / shift-* / rename-* / retire-* scripts (one-off maintenance, EP-era)
  • n8n workflow JSONs in backend/n8n (EP only; EM runs without n8n)

Read by: em-engine/lesson.js (sendDayContent, maxDay), em-engine/admin.js (EM Setup status: row/day counts; course max day), em-engine/hooks.js, routes/messages.js (streams lesson media for chat view), routes/trainer-dashboard.js (max day / speaking start day / reports), routes/bulk-message.js (finished-course filter), routes/finished-combined.js, rejoin.js, speaking-mark.js, trainer-app.js, middleware/verify-student-owner.js, verify-speaking-owner.js, frontend/src/components/LessonResourcesPanel.jsx, TrainerSupportPanel.jsx, ChatWindow.jsx, trainer-dashboard/buildCombinedReport.js

PK: id. Index on course_id. No unique constraint (nothing stops two rows having the same Day+Send_Order). No real foreign keys. Data: course 1, Grammar, days 1-54, 54 distinct days. Lesson-day flow: when a student taps Start, lesson.js selects rows WHERE course_id = student's course AND Lesson_Type = app_settings.em_lesson_type AND Day = current_lesson_day ORDER BY Send_Order, id; for each media row it calls ensureMediaId (uses cached Media_ID if not expired, else downloads File_Key from MinIO, uploads to WhatsApp and UPDATEs Media_ID / Media_Expiry_Date = today+29), sends, and waits for WhatsApp's 'sent' before the next row.

Media_Status_TrainerCore · lesson system0 rowsDelivery receipt book for lesson parts: one row per Lesson_Resources part the engine sends (the extra text sent before an audio, the day template, the trainer footer and the completion message get no row). WhatsApp status updates (sent, delivered, read, failed) then update the row by WhatsApp message id.
ColumnTypeWhat it stores
idbigint (serial)Row number.
createdAttimestamptzWhen the lesson message was sent.
updatedAttimestamptzWhen the latest status arrived.
createdByIdbigintNocoBase leftover; unused.
updatedByIdbigintNocoBase leftover; unused.
WA_NUMBERvarcharStudent's WhatsApp number the lesson went to (with 91 prefix).
BDA_WA_NUMBERvarcharEP-era: the trainer/sender number. EM's engine never fills it, so the dashboard's per-trainer media stats would group everything under blank.
Lesson_TypevarcharAlways 'Grammar' from the EM engine.
DayvarcharLesson day the message belongs to (stored as text).
course_idbigintCourse of the student.
OrderintegerPosition of the message within that day's send (1, 2, 3...).
Message_IDvarcharWhatsApp's id for the sent message (wamid); used to match status updates.
Media_TypevarcharResource_type of the lesson row: Text, Audio, Image...
Message_Statusvarcharsent -> delivered -> read, or 'Failed'.
Message_processedbooleanSet true at insert by the EM engine (EP used it to chain the next send).
Phone_Number_IDvarcharMeta phone-number id of the bot number that sent it (em_wa_numbers.phone_number_id).

Connected to

  • Lesson_Resources — (course_id, Lesson_Type, Day, Order) ~ Lesson_Resources (course_id, Lesson_Type, Day, position by Send_Order)
  • em_wa_numbers — Media_Status_Trainer.Phone_Number_ID -> em_wa_numbers.phone_number_id
  • Student_Details — Media_Status_Trainer.WA_NUMBER ~ Student_Details.Class_Mobile_Number / Mobile_Number (last 10 digits)
  • Trainers_info — Media_Status_Trainer.BDA_WA_NUMBER -> Trainers_info.Trainer_WA_Number (dashboard join; empty in EM)

Written by

  • backend/em-engine/lesson.js sendOne (INSERT one row per lesson message, status 'sent')
  • backend/em-engine/webhook.js onStatus (UPDATE Message_Status by Message_ID)
  • backend/em-engine/store.js ensureTables (adds Message_processed / Phone_Number_ID columns and Message_ID index)
  • n8n workflows in backend/n8n (EP only)

Read by: routes/trainer-dashboard.js (media delivery stats by type/status and by trainer), scripts/drain-lessons.js, em-engine/lesson.js (indirectly; ack waiting uses Chat History status, not this table)

PK: id. Indexes on Message_ID, BDA_WA_NUMBER, and (WA_NUMBER, Lesson_Type, Day, course_id, Order). No FKs. Empty because no lesson has been sent yet. Lesson-day flow: after each successful send in sendDayContent, one row is inserted with Message_Status='sent'; Meta's status webhook later updates it to delivered/read/Failed.

Student_Day_logsCore · lesson system0 rowsDiary of what happened to each student on each lesson day: when the day's invite was sent, when reminders went, when the student started the lesson, and when a trainer manually marked/sent a class. Reports use it as the real 'class sent'/'course finished' date.
ColumnTypeWhat it stores
idbigint (serial)Row number.
createdAttimestamptzWhen the event happened (used heavily by reports).
updatedAttimestamptzLast change.
createdByIdbigintNocoBase leftover; unused.
updatedByIdbigintNocoBase leftover; unused.
student_idbigintThe student (Student_Details.id).
trainer_idbigintThe trainer linked to the student (Trainers_info.id). The engine fills the student's first weekday trainer; may be empty on some event types.
lesson_dayvarcharLesson day number, stored as text.
event_typevarcharWhat happened: waiting_start (day invite template sent), follow_up_sent (reminder sent), waiting_completed (student tapped Start, lesson delivered), completed (trainer sent/marked the class manually from the dashboard).
Lesson_TypevarcharGrammar for the engine; dashboard routes also write Speaking, Kannada, Tamil, Telugu999 (EP features).

Connected to

  • Student_Details — Student_Day_logs.student_id -> Student_Details.id
  • Trainers_info — Student_Day_logs.trainer_id -> Trainers_info.id
  • Student_Progress — Student_Day_logs.student_id = Student_Progress.student_id (engine logs from the progress row)
  • t_ovaj35d9wum — trainer_id picked from weekday junction t_ovaj35d9wum (student_id1 = Student_Progress.id, student_id2 = Trainers_info.id)

Written by

  • backend/em-engine/lesson.js logDay (waiting_completed on Start tap)
  • backend/em-engine/cron.js (waiting_start on daily dispatch, follow_up_sent on reminders, via lesson.logDay)
  • backend/routes/trainer-dashboard.js (manual 'completed' class sends, speaking/Kannada/Tamil/Telugu999 logs)
  • backend/routes/trainer-app.js (trainer mobile app class actions)
  • backend/utils/stop-both.js (speaking class actions)
  • backend/routes/speaking-mark.js (DELETEs a log row when undoing)
  • backend/scripts/fix-lesson-day-overshoot.js and backend/scripts/fixes/*.sql (one-off fixes)

Read by: routes/trainer-dashboard.js (many reports, finish dates, day-1 started counts), routes/bulk-message.js (filter: students sent a given day), routes/finished-combined.js, revision-meet.js, ai-analysis.js, trainer-app.js, app-usage.js, mcp-connector/context.js, tools.js; mcp/tools/trainer.js, frontend/src/components/trainer-dashboard/buildCombinedReport.js

PK: id. Indexes: student_id; trainer_id; (student_id, event_type, createdAt desc); (createdAt desc, event_type). No FKs, no unique constraint (duplicates possible). Empty today. Gotcha: trainer-dashboard.js tries to insert class_type and notes columns, which do NOT exist in EM's table (code says it uses try/catch so the main action still works - those inserts would fail and fall back; not verified at runtime). Lesson-day flow: (1) 08:00 IST dispatch sends day template -> INSERT event 'waiting_start' with the new day; (2) no tap by Next_Followup_At -> reminder template -> INSERT 'follow_up_sent'; (3) student taps Start on the bot -> INSERT 'waiting_completed' then lesson content is sent.

app_settingsCore · lesson system24 rowsA simple key/value settings box. EM's lesson engine keeps all its switches here (em_* keys: send time, rest days, template, footer, course), and the dashboards keep editable message texts (class note, Day-4 intimation, revision meet messages). None of the 24 current rows is secret.
ColumnTypeWhat it stores
keytextSetting name, e.g. em_send_time.
valuetextSetting value (text). Secret values exist for some keys - (hidden).
updated_attimestamptzWhen last changed.
updated_bytextUsername of the admin who changed it (empty for seeded defaults).

Connected to

  • Course_Details — value of key em_course_id -> Course_Details.id
  • Lesson_Resources — em_lesson_type / em_course_id select which Lesson_Resources rows are sent

Written by

  • backend/em-engine/store.js (seeds em_* defaults; setSettings from Admin -> EM Setup via admin.js)
  • backend/em-engine/cron.js (em_last_dispatch_date, em_last_dispatch_result)
  • backend/index.js (seeds class note / Day-4 message defaults)
  • backend/app-settings-message.js, class-note-message.js, day4-intimation-message.js, revision-meet-message.js (editable message texts)
  • backend/trainer-shared-password.js (trainer_shared_password, hash/secret)
  • backend/mcp-connector/state.js

Read by: em-engine/store.js getSettings (cron, lesson, hooks), em-engine/admin.js, routes/trainer-dashboard.js (class_link_secret, telugu999_link_secret, class_link_start_date), routes/day4-intimation.js, revision-meet.js, mcp-connector/tools.js, frontend/src/components/ClassNotePanel.jsx (via API)

PK: key. Current 24 keys: em_completion_message, em_course_id=1, em_engine_enabled=true, em_footer_message, em_last_dispatch_date=2026-10-06, em_last_dispatch_result='no bot number registered', em_lesson_type=Grammar, em_rest_days=Sunday, em_send_time=08:00, em_start_on_any_message=true, em_template_lang=ml, em_template_name=em_day_test; plus class_note_message_tn/_telugu, day4_speaking_intimation_tn/_telugu, revision_meet1-3_basic/intermediate_tn, revision_prefix_reminder_tn/today_tn (EP-inherited message texts, Tamil/Telugu). em_last_dispatch_date acts as a once-per-day lock so a restart never sends twice.

em_wa_numbersCore · lesson system0 rowsThe registry of EM's WhatsApp numbers. The one active row with role 'bot' is THE lesson-sending number: every day-invite template, every lesson message, footer and completion message goes out from it. Rows with role 'trainer' are trainers' coexistence numbers whose chats are recorded. EMPTY today, so no lesson sending number is registered and the engine cannot send.
ColumnTypeWhat it stores
idinteger (serial)Row number. If several active bots exist, the lowest id is used.
labeltextFriendly name shown in Admin -> EM Setup.
roletext'bot' = lesson sending number; 'trainer' = a trainer's WhatsApp Business (coexistence) number.
wa_numbertextThe phone number, digits only (e.g. 91XXXXXXXXXX).
phone_number_idtextMeta's id for that number; used to send and to recognise incoming webhooks.
waba_idtextMeta WhatsApp Business Account id that owns the number.
access_token_enctextMeta access token for this number, (encrypted) with AES-256-GCM using EM_ENCRYPTION_KEY.
activebooleanOff = ignored for sending and for incoming webhooks.
created_attimestamptzWhen registered.
updated_attimestamptzLast edit.

Connected to

  • Media_Status_Trainer — Media_Status_Trainer.Phone_Number_ID -> em_wa_numbers.phone_number_id
  • p4boly0zb0p8fp1.Chat History — bot number's chats are written to the p4boly0zb0p8fp1 schema
  • py7a0z2w9agcvv2.Chat History — trainer numbers' chats are written to the py7a0z2w9agcvv2 schema
  • Trainers_info — em_wa_numbers.wa_number ~ Trainers_info.Trainer_WA_Number for trainer-role rows (by number, not verified in code)
  • backend/projects.config.json — bot wa_number/phone_number_id copied into the 'ep_trainer_api_tn' (EM Lessons Bot) chat section

Written by

  • backend/em-engine/store.js upsertNumber / deleteNumber (called from backend/em-engine/admin.js, Admin -> EM Setup, frontend EmSetupPanel.jsx)
  • backend/em-engine/store.js ensureTables (creates the table)

Read by: em-engine/store.js getBot (lesson sender), getNumberByPhoneNumberId (webhook), getNumberByWa, listNumbers, em-engine/cron.js, lesson.js, hooks.js, webhook.js (through store)

PK: id. UNIQUE wa_number, UNIQUE phone_number_id. CHECK role IN ('bot','trainer'). Lesson sending number = SELECT ... WHERE role='bot' AND active ORDER BY id LIMIT 1; currently none, which is why app_settings.em_last_dispatch_result says 'no bot number registered'. Tokens are never returned by the list API.

bulk_message_logSupport · screens & internals0 rowsRecord of every bulk WhatsApp message sent from the dashboard's Bulk Message screen: who got it, what it said, text or template, and who sent it. Used to list recipients later.
ColumnTypeWhat it stores
idbigint (serial)Row number.
phonevarcharRecipient phone (normalised).
messagetextText sent, or the template name/content.
modevarchar'text' or 'template'.
sent_byvarcharDashboard user who sent it.
sent_attimestamptzWhen it was sent.

Connected to

  • Student_Details — bulk_message_log.phone ~ Student_Details.Mobile_Number / Class_Mobile_Number

Written by

  • backend/routes/bulk-message.js (INSERT per recipient for text and template sends)

Read by: routes/bulk-message.js (recipient lists, 'already sent' filter), frontend/src/components/chat/BulkMessageChat.jsx (via API)

PK: id. Indexes on phone and mode. No FKs. Empty. Bulk sends go through the engine's bulk-text / bulk-template hooks, i.e. from the bot number.

em_media_importSupport · screens & internals338 rowsOne-time bookkeeping list from the import of EM's lesson audio/images into MinIO storage: which source file maps to which stored file name. No app code uses it.
ColumnTypeWhat it stores
file_keytextThe file name used in storage and in Lesson_Resources.File_Key, e.g. em_1_3_Audio.ogg.
srctextWhere the file came from in the old EM lesson copy, e.g. lessons/1/day01/03.ogg (day folder / send order).
copiedboolean (default false)Meant to flag that the file was copied. It is false on all 338 rows, yet the files are present in MinIO, so the flag was simply never updated.

Connected to

  • Lesson_Resources — em_media_import.file_key = Lesson_Resources.File_Key (all 338 match)

Written by

  • One-off import done during setup (no script for it found in /root/EMTrainerCommandCenter - not verified who/what wrote it)

Read by: Nothing in the code (grep finds no reference)

PK: file_key. Safe to treat as an audit trail of the media import; not needed at runtime. The 'copied' flag is stale (all f).

lesson_media_syncSupport · screens & internals0 rowsSide notebook for the Lesson Resources admin tab: for each lesson row it remembers when the media file was last replaced and when it was last uploaded ('synced') to WhatsApp. A row is shown as 'Needs sync' if it was replaced after its last sync.
ColumnTypeWhat it stores
lesson_idbigintWhich lesson message this is about (Lesson_Resources.id).
replaced_attimestamptzWhen an admin last uploaded a replacement file for this lesson row.
synced_attimestamptzWhen the file was last uploaded to WhatsApp (Meta) and a fresh Media_ID cached.

Connected to

  • Lesson_Resources — lesson_media_sync.lesson_id -> Lesson_Resources.id

Written by

  • backend/routes/lesson-resources.js (creates table if missing; sets replaced_at on file replace, synced_at on Meta sync)
  • backend/em-engine/lesson.js ensureMediaId (upserts synced_at after every upload; errors ignored)
  • backend/scripts/meta-sync-batch.js

Read by: routes/lesson-resources.js (LEFT JOIN to compute Synced / Needs sync status)

PK: lesson_id (one row per lesson message). Created by code at startup (CREATE TABLE IF NOT EXISTS), also in backend/db/em-schema.sql. Empty today because no media has been uploaded to WhatsApp yet. Status rule in Lesson Resources: no File_Key = text; File_Key but no Media_ID = Needs sync; replaced after last sync = Needs sync; otherwise Synced. Today all 338 media rows show Needs sync.

Trainer_RatingEP-only · unused on EM0 rowsStudents' star ratings and 'what did you learn today' feedback after a class. In EP it was filled by n8n rating workflows; EM has no code that writes it, so it stays empty while the dashboards still read it.
ColumnTypeWhat it stores
idbigint (serial)Row number.
createdAttimestamptzWhen the rating was given.
updatedAttimestamptzLast change.
createdByIdbigintNocoBase leftover; unused.
updatedByIdbigintNocoBase leftover; unused.
student_idbigintStudent who rated (Student_Details.id).
RatingintegerStar score.
Class_Attended_onvarcharDate of the class being rated (text).
What_did_you_learn_todaytextStudent's free-text answer.
Lesson_TypevarcharGrammar or Speaking.
DayvarcharLesson day rated.

Connected to

  • Student_Details — Trainer_Rating.student_id -> Student_Details.id
  • Trainers_info — rating attributed to trainer through the junction t_ovaj35d9wum (student -> trainer), not stored on the row

Written by

  • Nothing in EM. EP: n8n 'Rating Schedule Grammar/Speaking' workflows (copies in backend/n8n/mastery)

Read by: routes/trainer-dashboard.js (trainer rating summaries and reports)

PK: id. No other indexes or constraints. Empty. Rating screens in the dashboard will show nothing for EM until a rating collection flow is built.

Chats & contacts · 8 tables

p4boly0zb0p8fp1.Chat HistoryCore · lesson system0 rowsThe message log of the LESSON BOT number (the WhatsApp number that sends the daily lessons). Every lesson template, lesson text/audio/image/video, footer, completion message, and every student reply (including the Start button tap) on the bot number is one row. The cryptic schema name p4boly0zb0p8fp1 is a NocoDB-generated base id inherited from EP; in the app it is the project 'ep_trainer_api_tn' shown as 'EM Lessons Bot'.
ColumnTypeWhat it stores
idintegerRow number (auto).
created_attimestamp with time zoneWhen the row was inserted (default now()).
updated_attimestamp with time zoneLast change, e.g. when the delivery status or media link was updated.
Phone_NumbertextOur own WhatsApp number (the bot number) this conversation belongs to. The engine always fills it with the registered bot wa_number.
From_Phone_NumbertextWho sent the message: the student's number for incoming rows, the bot's number for outgoing rows. For incoming rows this is the 'contact' in the chat screen.
To_Phone_NumbertextWho received it: the student's number (with 91 prefix) for outgoing lesson rows, the bot number for incoming rows.
TexttextMessage text or caption. Templates are stored as 'Template: <template name>'. Button taps store the button text (e.g. Start).
Message_TypetextKind of message, capitalised: Text, Template, Audio, Image, Video, Document, Button, Interactive, Reaction, Sticker, etc.
TypetextDirection: 'messages' = incoming from the student, 'smb_message_echoes' = outgoing from us (names inherited from Meta/EP).
Timestamptimestamp with time zoneWhen the message was sent/received (Meta's timestamp for incoming; now() for outgoing). All screens sort/filter on this.
Message_StatustextDelivery status: accepted (just sent) -> sent -> delivered -> read, or failed; 'received' for incoming. Updated by Meta status webhooks.
Media_IDtextMeta's media id for audio/image/video/document messages.
Message_IDtextMeta's unique message id (wamid). Used to stop duplicates and to match status updates.
PayloadtextRaw JSON of the webhook / send payload, kept for debugging.
MediatextJSON attachment list pointing to the stored copy of the media in MinIO (path download/em/...), so the chat screen can play it.
Phone_Number_IDtextMeta's Phone Number ID of our bot number (links to em_wa_numbers.phone_number_id).
ProgresstextLegacy EP field mapped as 'progress' in projects.config; not written by the EM engine (not verified any EM code fills it).

Connected to

  • em_wa_numbers — Chat History.Phone_Number / Phone_Number_ID -> em_wa_numbers.wa_number / phone_number_id (role='bot')
  • Student_Details — last 10 digits of From_Phone_Number / To_Phone_Number -> Student_Details.Class_Mobile_Number or Mobile_Number
  • Media_Status_Trainer — Chat History.Message_ID = Media_Status_Trainer.Message_ID (one row per lesson part sent)
  • chat_read_state — chat_read_state.project_id='ep_trainer_api_tn' + wa_number=Phone_Number + contact_number=From_Phone_Number

Written by

  • backend/em-engine/chat.js (insertRow/logOutgoing/updateStatus/setMediaByMediaId - the only writer)
  • backend/em-engine/lesson.js (daily template, lesson parts, footer, completion message via chat.logOutgoing)
  • backend/em-engine/webhook.js (incoming student messages incl. Start tap, status updates, media)
  • backend/em-engine/hooks.js (replies/templates sent from the dashboard through the bot)

Read by: routes/messages.js + frontend/src chat view (EM Lessons Bot chat section), routes/read-state.js (unread counts), routes/nocodb-integration.js (API delivery stats), routes/trainer-dashboard.js (Start-tap attendance, API report), routes/student-journey.js, routes/silent-students.js, routes/rejoin.js, routes/revision-meet.js, routes/finished-combined.js, routes/trainer-app.js, em-engine/chat.js (messageExists/getStatus for dedup and waitForAck), n8n/999-link/monitor-*-link.js and scripts/* (EP 999/Tamil scripts, EP-only)

PK: id. No unique constraint; duplicates are prevented in code by checking Message_ID first (messageExists). Indexes on Message_ID (twice: idx_chat_message_id and idx_p4boly0zb0p8fp1_msgid), (Phone_Number_ID, Message_ID, To_Phone_Number), From_Phone_Number(+Timestamp), To_Phone_Number+Timestamp, Message_Type+Timestamp, Phone_Number+Timestamp, Timestamp. Empty today because no lesson bot number is registered yet: public.em_wa_numbers has 0 rows, projects.config.json 'ep_trainer_api_tn' has singleWaNumber='' and app_settings em_last_dispatch_result = '2026-10-06 ... no bot number registered'. So the LESSON SENDING NUMBER is whatever number is added in Admin -> EM Setup with role 'bot' (first active bot row); none is set now. 918838827955 appears only in an EP code comment (EP Grammar bot), not EM. Student's day: (1) daily dispatch at em_send_time 08:00 IST (rest day Sunday) sends template em_day_test -> one outgoing row Message_Type='Template', Text='Template: em_day_test', status 'accepted'; (2) Meta status webhooks update Message_Status to sent/delivered/read on the same row; (3) student taps Start -> incoming row Type='messages', Message_Type='Button'/'Interactive' (em_start_on_any_message=true so any message counts) and Student_Progress moves to Waiting_completed; (4) engine sends every Lesson_Resources row for that day in Send_Order: one outgoing row per text/media (audio with text gets two rows), waiting for each to be acknowledged before the next; plus a footer row with trainer names and, on the last day, the completion message row; (5) inbound media is copied to MinIO and the Media column filled.

py7a0z2w9agcvv2.Chat HistoryCore · lesson system0 rowsThe message log of the TRAINERS' own WhatsApp numbers (WhatsApp Business app in coexistence mode). Trainer <-> student conversations land here: student messages to a trainer, and messages a trainer sends from the phone app (echoes) or from the dashboard. In the app this is project 'ep_trainer_bot' shown as 'EM Trainers'. In EP the same schema also held the sales/BDA chat, which is why it has extra legacy columns.
ColumnTypeWhat it stores
idintegerRow number (auto).
created_attimestamp with time zoneWhen inserted.
updated_attimestamp with time zoneLast change (status/media update).
Phone_NumbertextThe trainer's WhatsApp number this chat belongs to.
From_Phone_NumbertextSender: student for incoming, trainer for outgoing.
To_Phone_NumbertextReceiver: student for outgoing, trainer for incoming.
TexttextMessage text or caption.
Message_TypetextText, Audio, Image, Video, Document, Template, Reaction, etc.
Typetext'messages' = incoming from student, 'smb_message_echoes' = sent by the trainer.
Timestamptimestamp with time zoneMessage time; main sort/filter column.
Message_Statustextaccepted/sent/delivered/read/failed or received.
Media_IDtextMeta media id.
Message_IDtextMeta message id (dedup + status matching).
PayloadtextRaw webhook JSON.
MediatextJSON pointer to the MinIO copy of the media.
Phone_Number_IDtextMeta Phone Number ID of the trainer number.
ProgresstextLegacy EP field; not filled by EM engine (not verified).
UsertextLegacy EP sales-chat column: the contact number. Written only by the fallback path in routes/messages.js.
Output_Message__Bot_textLegacy EP sales-chat column: the text sent from the dashboard (fallback path).
Output_Message_Timingtimestamp with time zoneLegacy EP sales-chat column: when that dashboard message was sent.

Connected to

  • em_wa_numbers — Phone_Number / Phone_Number_ID -> em_wa_numbers (role='trainer')
  • Trainers_info — Phone_Number -> Trainers_info.Trainer_WA_Number
  • Student_Details — last 10 digits of From_/To_Phone_Number -> Student_Details mobile numbers
  • py7a0z2w9agcvv2.Contact Table — Phone_Number + From_Phone_Number -> Contact Table.Phone_Number + Contact_Number (saved name)
  • chat_read_state — project_id='ep_trainer_bot', wa_number=Phone_Number, contact_number=From_Phone_Number
  • ai_chat_analyses — ai_chat_analyses.wa_number/contact_number = the conversation analysed

Written by

  • backend/em-engine/chat.js (insertRow, updateStatus, setMediaByMediaId)
  • backend/em-engine/webhook.js (incoming messages, trainer echoes, statuses)
  • backend/em-engine/hooks.js (dashboard replies/audio/templates sent via a trainer number)
  • backend/routes/messages.js (legacy fallback insert of User/Output_Message__Bot_/Output_Message_Timing when project has no singleWaNumber)

Read by: routes/messages.js + frontend/src/components/chat (EM Trainers chat), routes/read-state.js, routes/trainer-dashboard.js, routes/student-trainers.js, routes/student-journey.js, routes/silent-students.js, routes/sales-dashboard.js (EP sales, ep_only use), routes/bulk-message.js, routes/filters.js, routes/nocodb-integration.js, mcp/tools/messages.js, mcp-connector/*

PK: id; no unique constraint (dedup in code via Message_ID). Many indexes incl. trigram GIN on Text (search), Phone_Number+From_Phone_Number+Timestamp (thread view), partial indexes for echoes/messages, Message_ID. Empty today: no trainer number registered in em_wa_numbers. Possible duplicate (not verified): for ep_trainer_bot (no singleWaNumber) a dashboard send writes a legacy row in routes/messages.js AND the engine reply hook logs a proper outgoing row.

chat_read_stateSupport · screens & internals2 rowsRemembers, per dashboard user, which chats they have read, so the chat list can show unread badges and 'mark as unread'. One row per user + chat section + our number + contact.
ColumnTypeWhat it stores
idintegerRow number.
usernamecharacter varyingDashboard login name (e.g. admin).
project_idcharacter varyingChat section: ep_trainer_api_tn (Lessons Bot) or ep_trainer_bot (Trainers).
wa_numbercharacter varyingOur WhatsApp number of that chat.
contact_numbercharacter varyingStudent/contact number.
last_read_attimestamp with time zoneWhen the user last opened the chat; incoming messages after this count as unread.
manually_unreadbooleanTrue if the user clicked 'mark as unread'.
updated_attimestamp with time zoneLast change.

Connected to

  • Chat History (either schema, chosen by project_id) — wa_number + contact_number -> Chat History.Phone_Number + From_Phone_Number

Written by

  • backend/routes/read-state.js (auto-seed on first load for contacts of last 14 days, mark-read, mark-unread)
  • backend/index.js (creates table at startup)

Read by: routes/read-state.js (/api/unread-counts), frontend/src/App.jsx, frontend/src/components/chat/ChatMode.jsx (via API)

PK: id. UNIQUE (username, project_id, wa_number, contact_number) - upserts use ON CONFLICT on it. Index (username, project_id, wa_number). Current 2 rows are test data: admin, ep_trainer_api_tn, 919000000001 with contacts 919000000011/12 (2026-10-06).

py7a0z2w9agcvv2.Contact TableSupport · screens & internals0 rowsSaved WhatsApp contact names for trainer numbers (an address book). The filter/contact list merges it with Student_Details so chats show a name instead of only a number. Nothing in EM fills it today.
ColumnTypeWhat it stores
idintegerRow number.
created_attimestamp with time zoneInserted at.
updated_attimestamp with time zoneUpdated at.
Phone_NumbertextTrainer's WhatsApp number (whose address book).
Contact_NumbertextContact's number.
Contact_NametextDisplay name for that contact.

Connected to

  • py7a0z2w9agcvv2.Chat History — Phone_Number + Contact_Number -> Chat History.Phone_Number + From_Phone_Number

Written by

  • none found in EM code (EP filled it via n8n)

Read by: routes/filters.js (contact list/name lookup), mcp/tools/contacts.js

PK: id; index on Phone_Number. No unique constraint, so the same contact could be saved twice.

ai_agent_sessionsEP-only · unused on EM0 rowsConversation memory of the 'Forge Agent' AI assistant widget: each user's chat with the assistant (messages as JSON) so it can continue later. Inherited from EP; unused on EM.
ColumnTypeWhat it stores
idintegerRow number.
session_iduuidUnique id of the assistant conversation.
usernametextDashboard user.
project_idtextChat section the user was in.
messagesjsonbFull list of assistant conversation messages.
tool_calls_countintegerHow many data lookups the assistant made.
started_attimestamp with time zoneSession start.
last_active_attimestamp with time zoneLast message time.
is_activebooleanFalse after the user clears the session.
metadatajsonbExtra info (free-form).

Connected to

  • None

Written by

  • backend/routes/forge-agent.js (/api/agent/chat insert/update, /api/agent/session/clear)

Read by: routes/forge-agent.js (/api/agent/session), frontend/src/components/forge-agent/ForgeAgentWidget.jsx (via API)

PK: id. UNIQUE session_id (plus a redundant index idx_agent_sessions_id). Index (username, last_active_at DESC). Uses OpenAI (paid) when used.

ai_analysis_feedbackEP-only · unused on EM0 rowsThumbs up/down and comments users give on an AI chat analysis, to judge AI quality. Inherited from EP; unused on EM.
ColumnTypeWhat it stores
idintegerRow number.
analysis_idintegerWhich AI analysis is rated (ai_chat_analyses.id).
project_idtextChat section.
ratingcharacter varyingthumbs_up or thumbs_down.
categoriesjsonbProblem tags chosen (list).
commenttextFree-text comment.
submitted_bytextUser who rated.
submitted_attimestamp with time zoneWhen rated.
updated_attimestamp with time zoneLast change.

Connected to

  • ai_chat_analyses — ai_analysis_feedback.analysis_id -> ai_chat_analyses.id (ON DELETE CASCADE) (enforced)

Written by

  • backend/routes/ai-analysis.js (feedback endpoint, upsert)

Read by: routes/ai-analysis.js (feedback stats/list), frontend/src/components/AIChatAnalysis.jsx (via API)

PK: id. FK analysis_id -> ai_chat_analyses(id) ON DELETE CASCADE. CHECK rating IN ('thumbs_up','thumbs_down'). UNIQUE index (analysis_id, submitted_by) = one rating per user per analysis. Index (project_id, submitted_at DESC).

ai_chat_analysesEP-only · unused on EM0 rowsSaved results of the 'AI Chat Analysis' button (OpenAI reads a conversation and writes a summary), plus token-usage logs of the Forge Agent assistant. Feeds the AI usage/cost dashboard. Inherited from EP; unused on EM so far.
ColumnTypeWhat it stores
idintegerRow number.
project_idtextChat section analysed.
wa_numbertextOur number of the conversation.
contact_numbertextStudent/contact of the conversation.
raw_texttextThe AI's answer as text.
parsedjsonbThe AI answer split into structured fields.
message_countintegerHow many messages were analysed.
audio_transcribedintegerHow many voice notes were transcribed.
modeltextOpenAI model used.
analyzed_attimestamp with time zoneWhen run.
analyzed_bytextDashboard user who ran it.
prompt_tokensintegerInput tokens (cost).
completion_tokensintegerOutput tokens (cost).
total_tokensintegerTotal tokens.
images_analyzedintegerImages described.
sourcecharacter varying'ai_analysis' or the Forge Agent source label.
audio_duration_secondsnumericSeconds of audio transcribed (cost).
prompt_versioncharacter varyingShort version tag of the prompt used.
prompt_texttextFull prompt used.

Connected to

  • ai_analysis_feedback — ai_analysis_feedback.analysis_id -> ai_chat_analyses.id (enforced)
  • Chat History — project_id + wa_number + contact_number = analysed conversation

Written by

  • backend/routes/ai-analysis.js (/api/ai/analyze, analyze-stream)
  • backend/routes/forge-agent.js (token usage log)

Read by: routes/ai-analysis.js (/api/ai/history), routes/ai-usage.js, frontend/src/components/AIChatAnalysis.jsx, AIUsageDashboard.jsx (via API)

PK: id. Indexes (project_id, wa_number, contact_number, analyzed_at DESC) and (source, project_id, analyzed_at DESC). Calls OpenAI (paid) when used.

p4boly0zb0p8fp1.Contact TableEP-only · unused on EM0 rowsSaved contact names for the lesson-bot number (EP NocoDB leftover). No EM code reads or writes it.
ColumnTypeWhat it stores
idintegerRow number.
created_attimestamp with time zoneInserted at.
updated_attimestamp with time zoneUpdated at.
Phone_NumbertextOur WhatsApp number.
Contact_NumbertextThe contact's number.
Contact_NametextName saved for that contact.

Connected to

  • p4boly0zb0p8fp1.Chat History — Phone_Number + Contact_Number -> Chat History.Phone_Number + From_Phone_Number

Written by

  • none found (only CREATE in backend/db/em-schema.sql)

Read by: none found

PK: id. No other indexes. Created only so the schema matches EP.

Logins, settings & app internals · 9 tables

wa_viewer_accessCore · lesson system1 rowsThe staff login and permission list for the EM Trainers web dashboard. Each row is one person who can sign in: their role (admin/viewer etc.), which regions/teams/projects of trainers they may see, and their password (stored as a hash). Trainers on the mobile app do NOT use this table — they log in by phone + shared password checked against Trainers_info.
ColumnTypeWhat it stores
idintegerRow number. Primary key.
nocobase_user_idintegerLink to users.id (name is inherited from EP's NocoBase). Optional for new users created with a local password.
usernamecharacter varyingThe login name typed on the sign-in page. Unique.
display_namecharacter varyingName shown in the dashboard (e.g. 'EM Admin').
rolecharacter varyingWhat the person is: admin, viewer, content_viewer, or rejoin_agent (CHECK constraint). Default viewer.
scopecharacter varying'all' = sees every trainer; 'restricted' = only the regions/teams/projects listed below (CHECK constraint).
allowed_regionsARRAY (text[])Regions the person may see; matched against Trainers_info."Region".
allowed_teamsARRAY (text[])Teams the person may see; in code teams map to trainer shifts (Trainers_info."Shift").
allowed_projectsARRAY (text[])Projects/course groups the person may see (checked by checkProjectAccess).
created_attimestamp with time zoneWhen the login was created.
updated_attimestamp with time zoneWhen permissions were last changed.
demo_modebooleanIf true, the person sees masked/demo data. Always false for admins.
read_onlybooleanIf true, the person can look but cannot send lessons or change anything.
password_hashtext(hash) The person's password, stored scrambled. Never shown.

Connected to

  • users — wa_viewer_access.nocobase_user_id -> users.id (enforced)
  • Trainers_info — allowed_regions / allowed_teams matched in code to Trainers_info.Region / Shift (getUserAllowedTrainerNumbers in auth.js)
  • app_usage_events / app_usage_daily — username appears as the 'actor' of web-dashboard usage rows

Written by

  • backend/auth.js bootstrapEmAdmin() — first admin on startup
  • backend/auth.js POST /api/auth/users — admin adds a login (AdminPanel.jsx)
  • backend/auth.js PUT /api/auth/users/:username — admin changes permissions
  • backend/auth.js DELETE /api/auth/users/:username — admin removes a login
  • backend/auth.js — CREATE TABLE IF NOT EXISTS / ALTER TABLE add columns at startup

Read by: auth.js POST /api/auth/login — finds the person and checks the password, auth.js GET /api/auth/me — reloads demo_mode/read_only/role/scope each request, auth.js GET /api/auth/users — admin user list, frontend/src/components/AdminPanel.jsx (via the /api/auth/users endpoints)

PK id; UNIQUE username; FK nocobase_user_id -> users(id) ON DELETE CASCADE; CHECK on role and scope. Current row: id 1, 'admin', role admin, scope all, has a password hash. Cryptic name: inherited from EP where it meant 'who may view the WhatsApp dashboard'. Every dashboard sign-in depends on it, hence core.

app_usage_dailySupport · screens & internals4 rowsOne summary line per person, per app, per day (India time): how many requests, actions, lesson/message sends, failures and logins that person made. It is the cheap 'running totals' table behind the admin App Usage screen, and it is also used to know which trainers opened the mobile app in the last 14 days.
ColumnTypeWhat it stores
actortextWho: a dashboard username (e.g. 'admin') or a trainer's phone number for the mobile app.
apptextWhich app: 'web' (dashboard), 'trainer_app', or BDA app.
daydateCalendar day in India time (IST). Note: a row dated 2026-10-15 exists because IST is ahead of UTC.
actor_roletextRole at the time (admin, viewer...).
requestsintegerCount of tracked API calls that day.
actionsintegerCount of meaningful actions (matched in app-usage-actions.js).
sendsintegerCount of send actions (lessons, classes, messages).
failuresintegerCount of actions that failed (HTTP error).
sessionsintegerCount of logins / token refreshes.
first_seen_attimestamp with time zoneEarliest activity that day.
last_seen_attimestamp with time zoneLatest activity that day.

Connected to

  • app_usage_events — same (actor, app, day) — daily is the rollup of events plus counters that never become event rows
  • wa_viewer_access — actor = wa_viewer_access.username for app='web'
  • Trainers_info — actor = trainer WhatsApp number for app='trainer_app'

Written by

  • backend/app-usage.js flushDaily() — every 60 s, upsert ON CONFLICT (actor,app,day) adding counters; fed by appUsageMiddleware (backend/index.js), noteSession() in auth.js trainer login and routes/bda-app.js
  • backend/scripts/backfill-app-usage-from-logs.js (one-off backfill)

Read by: routes/app-usage.js GET /api/admin/app-usage (frontend/src/components/AppUsagePanel.jsx in AdminPanel), routes/trainer-app.js warmTrainerChatMaps() — trainers active in last 14 days

PK (actor, app, day). Indexes (day DESC, app) and (actor, day DESC). Tracking must never affect a request: writes are buffered in memory and swallowed on error. Not part of lesson sending.

app_usage_eventsSupport · screens & internals3 rowsA detailed diary of important actions people take in the dashboard and mobile apps: who did what, on which screen/API, for which student/target, whether it worked and how long it took. High-frequency screen refreshes are not stored here, only in the daily totals. Rows older than 400 days are deleted automatically.
ColumnTypeWhat it stores
idbigintEvent number. Primary key.
occurred_attimestamp with time zoneExact time of the action.
daydateIST calendar day of the action.
apptextweb / trainer_app / BDA app.
actortextWho did it (username or trainer phone).
actor_roletextTheir role.
trainer_idintegerTrainers_info id when the actor is a trainer.
actiontextShort action name, e.g. trainer_login, students_export, followup_report_view.
categorytextGroup of action: auth, report, send, etc.
routetextAPI path that was called.
methodtextGET/POST/...
course_typetextCourse involved, when known.
target_typetextWhat kind of thing was acted on (student, trainer...).
target_idtextId of that thing.
target_phonetextPhone of the student/trainer acted on, when known.
target_countintegerHow many targets (e.g. bulk send to N students). Default 1.
status_codesmallintHTTP result code (200 ok, 403 forbidden...).
okbooleanWhether it succeeded.
duration_msintegerHow long the request took.
clienttextClient type (browser / app).
client_versiontextApp version, when sent.
sourcetext'live' (real-time) or backfill.
dedupe_keytextUsed only by the backfill script so the same log line is not inserted twice.
metajsonbExtra details.

Connected to

  • app_usage_daily — (actor, app, day) rolled up into daily
  • Trainers_info — trainer_id -> Trainers_info.id
  • wa_viewer_access — actor = wa_viewer_access.username

Written by

  • backend/app-usage.js flushEvents() — every 2 s or at 100 buffered events, from appUsageMiddleware (backend/index.js) and recordEvent() in backend/auth.js trainer login
  • backend/app-usage.js startAppUsageFlushers() — deletes rows older than 400 days
  • backend/scripts/backfill-app-usage-from-logs.js

Read by: routes/app-usage.js GET /api/admin/app-usage and /api/admin/app-usage/events (AppUsagePanel.jsx), mcp-connector/tools.js list_app_usage_events (MCP tool, admin_ops capability)

PK id; partial UNIQUE index on dedupe_key (when not null); indexes on (day,app), (day,action), (actor,day), occurred_at, and failed rows. Samples: two admin report views that returned 403 and one trainer_login by a test number 919000000201.

app_usage_metaSupport · screens & internals1 rowsA tiny notebook for the usage tracker. Today it holds one fact: when usage tracking started, so the App Usage screen can say 'data since ...'.
ColumnTypeWhat it stores
keytextName of the fact. Primary key. Only row: tracking_started_at.
valuetextThe fact's value (here 2026-10-06 10:03:52 UTC).
updated_attimestamp with time zoneWhen it was written.

Connected to

  • None

Written by

  • backend/app-usage.js ensureAppUsageTables() — inserts tracking_started_at once at startup

Read by: app-usage.js getTrackingSince() used by routes/app-usage.js and scripts/backfill-app-usage-from-logs.js

PK key. No foreign keys.

mcp_connector_keysSupport · screens & internals0 rowsAPI keys that let an outside AI assistant (via the MCP connector) read EM Trainers data in read-only mode. Only a scrambled copy of each key is stored, plus a short start/end so the admin can recognise it. The on/off switch and allowed tool groups live in app_settings, not here. Empty: no key has been issued.
ColumnTypeWhat it stores
idintegerKey number. Primary key.
labeltextAdmin's name for the key (who/what it is for).
key_prefixtextFirst few characters of the key, shown so it can be recognised.
key_suffixtextLast few characters of the key, shown so it can be recognised.
key_hashtext(hash) Scrambled full key, used to check incoming requests. Unique.
enabledbooleanWhether the key currently works.
created_attimestamp with time zoneWhen the key was issued.
created_bytextAdmin username who issued it.
last_used_attimestamp with time zoneLast time the key was used.

Connected to

  • app_settings — keys 'mcp_connector_enabled' and 'mcp_connector_capabilities' in app_settings control the connector (same module)
  • wa_viewer_access — created_by = admin username

Written by

  • backend/mcp-connector/state.js — create table at startup (via backend/index.js), INSERT new key, UPDATE enabled, DELETE key, UPDATE last_used_at on each use
  • backend/routes/admin-mcp-tools.js POST/PATCH/DELETE /api/admin/mcp-tools/keys (McpToolsPanel.jsx in AdminPanel)

Read by: mcp-connector/state.js — key check for routes/mcp-http.js, key list for the admin panel

PK id; UNIQUE key_hash. 0 rows.

trainer_call_reportsSupport · screens & internals0 rowsSpeaking trainers' daily call log, which replaces the old WhatsApp 'Daily Report' group. For each student call, the trainer uploads the call screenshot and types the duration. A logged call counts as proof that the student did their speaking work, so student journey and silent-student screens read it. Empty so far.
ColumnTypeWhat it stores
idintegerReport number. Primary key.
trainer_watextTrainer's WhatsApp number (who made the call).
trainer_idintegerTrainers_info id, for reference.
student_phonetextStudent called. Readers compare it after removing non-digits.
student_nametextStudent's name as entered.
coursetextCourse of the student.
image_urltextCall screenshot link (file in /app/uploads/reports).
duration_texttextCall length as typed by the trainer (free text).
report_datedateIST day of the call.
created_attimestamp with time zoneWhen uploaded.

Connected to

  • Trainers_info — trainer_wa = Trainers_info.Trainer_WA_Number; trainer_id -> Trainers_info.id
  • Student_Details (student tables) — digits of student_phone = student's phone number

Written by

  • backend/routes/call-report.js POST /api/trainer/report (mobile app, one INSERT per call)
  • backend/routes/call-report.js DELETE /api/trainer/report/:id
  • backend/routes/call-report.js / backend/db/em-schema.sql — create table

Read by: routes/call-report.js GET /api/trainer/report/today and GET /api/trainer-dashboard/call-reports (ActivityTab.jsx), routes/student-journey.js — calls per day on the student timeline, routes/student-trainers.js, routes/silent-students.js — a student with a logged call is not counted as silent

PK id; index (trainer_wa, report_date). Many rows per trainer per day are allowed. 0 rows. Not part of lesson sending.

trainer_shift_logs_gsSupport · screens & internals0 rowsDaily attendance proof for trainers: one row per trainer per IST day with the screenshot they uploaded from the mobile app at LOGIN (start of work), BREAK and LOGOUT, and when. Shown on the dashboard's Activity tab. Empty: no EM trainer has uploaded yet.
ColumnTypeWhat it stores
idintegerRow number. Primary key.
trainer_watextTrainer's WhatsApp number. This, not trainer_id, identifies the trainer.
trainer_idintegerFirst matching Trainers_info id, kept for reference only.
shift_datedateThe IST working day.
start_urltextOld start-of-shift screenshot link. Kept but unused.
start_attimestamp with time zoneOld start time. Unused.
end_urltextOld end-of-shift screenshot link. Unused.
end_attimestamp with time zoneOld end time. Unused.
login_urltextLOGIN screenshot link (file in /app/uploads/shift).
login_attimestamp with time zoneWhen LOGIN was uploaded.
break_urltextBREAK screenshot link.
break_attimestamp with time zoneWhen BREAK was uploaded.
logout_urltextLOGOUT screenshot link.
logout_attimestamp with time zoneWhen LOGOUT was uploaded.
created_attimestamp with time zoneRow created.
updated_attimestamp with time zoneRow last changed.

Connected to

  • Trainers_info — trainer_wa = Trainers_info.Trainer_WA_Number; trainer_id -> Trainers_info.id

Written by

  • backend/routes/shift.js POST /api/trainer/shift/... (trainer mobile app upload) — upsert ON CONFLICT (trainer_wa, shift_date) setting the kind's url/at
  • backend/routes/shift.js DELETE /api/trainer/shift/:kind — clears one screenshot
  • backend/routes/shift.js / backend/db/em-schema.sql — create table

Read by: routes/shift.js GET /api/trainer/shift/today (mobile app), routes/shift.js GET /api/trainer-dashboard/trainer-activity (frontend/src/components/trainer-dashboard/ActivityTab.jsx)

PK id; UNIQUE (trainer_wa, shift_date). '_gs' suffix is inherited from EP: the GS backend keyed by WA number, the O3 backend had its own table. 0 rows.

usersSupport · screens & internals1 rowsA bare-bones list of people accounts, kept only because the login table (wa_viewer_access) must point at a row here. In EP this was NocoBase's own user table; EM has no NocoBase, so the only row is the 'admin' account the backend created on first start.
ColumnTypeWhat it stores
idbigintAccount number (auto-numbered). Primary key.
createdAttimestamp with time zoneWhen the account row was created (NocoBase-style camelCase name).
updatedAttimestamp with time zoneWhen the account row was last changed.
nicknamecharacter varyingDisplay name. The bootstrap admin gets the same value as username ('admin').
emailcharacter varyingEmail. Unique. The bootstrap sets it to the username, not a real email. Login can also match on this email.
usernamecharacter varyingLogin name. Unique.

Connected to

  • wa_viewer_access — wa_viewer_access.nocobase_user_id -> users.id (ON DELETE CASCADE) (enforced)

Written by

  • backend/auth.js bootstrapEmAdmin() — INSERT once when no admin exists (uses EM_ADMIN_USERNAME / EM_ADMIN_PASSWORD env)
  • backend/db/em-schema.sql (creates table; seed INSERT is commented out)

Read by: auth.js POST /api/auth/login — email fallback (JOIN users on wa_viewer_access.nocobase_user_id), auth.js GET /api/auth/nocobase-users — lists accounts not yet linked to a login (AdminPanel.jsx 'add user' picker)

PK id; UNIQUE email; UNIQUE username. Sample: id 1, nickname/username 'admin'. Holds no password — passwords live in wa_viewer_access.password_hash. Nothing here relates to lessons or the lesson-sending WhatsApp number.

dashboard_settingsEP-only · unused on EM3 rowsSmall key/value store of colour thresholds for the trainer-performance dashboard: how many minutes counts as a good (green) / slow (yellow/orange) reply, how long a call must be, and which hours are morning/evening/night shift. The backend creates and seeds it, but no EM screen currently calls the endpoint that reads it.
ColumnTypeWhat it stores
keycharacter varyingName of the setting. Primary key. Rows: response_time_thresholds, duration_thresholds, shift_hours.
valuejsonbThe setting's value, e.g. {green:5, yellow:15, orange:30} minutes for reply time; {green:30, yellow:15} for call duration; {morning:[8,16], evening:[16,24], night:[0,8]} hours.
updated_attimestamp with time zoneWhen the setting was last changed (all three seeded 2026-10-06).

Connected to

  • None

Written by

  • backend/routes/trainer-dashboard.js ensureDashSettingsTable() — creates and seeds the 3 defaults (ON CONFLICT DO NOTHING)
  • backend/routes/trainer-dashboard.js PATCH /api/trainer-dashboard/settings/:key

Read by: routes/trainer-dashboard.js GET /api/trainer-dashboard/settings (no caller found in frontend/src — not verified whether any app calls it)

PK key. No foreign keys. Values are defaults only; nobody has edited them. Classified ep_only because no EM screen reads it (not verified for external clients).

Inherited from EP (sales, 999 courses, class links, revision, speaking) · 23 tables

Counsellor_DetailsSupport · screens & internals0 rowsThe list of sales counsellors (BDAs): name, employee id, region and team. Dashboards, the BDA app and student drawers use it to show the counsellor's name for a student or a sales number.
ColumnTypeWhat it stores
idbigintCounsellor id (primary key)
createdAttimestamp with time zoneCreated time
updatedAttimestamp with time zoneLast changed time
createdByIdbigintNocoBase creator (unused)
updatedByIdbigintNocoBase last editor (unused)
Namecharacter varyingCounsellor's name
EMP_IDcharacter varyingEmployee id
Regioncharacter varyingSales region
Teamcharacter varyingSales team
Part_ofcharacter varyingParent group or department (not verified)
Statuscharacter varyingActive or inactive status

Connected to

  • Bot_Config — Bot_Config.counsellor_id -> Counsellor_Details.id
  • Student_Details — Student_Details.counselor_id -> Counsellor_Details.id
  • Transaction_details — Transaction_details.counsellor_id -> Counsellor_Details.id
  • Sales_Update_Log — Sales_Update_Log.counsellor_id -> Counsellor_Details.id

Written by

  • No code writes it (NocoBase or manual entry in EP). Defined in backend/db/em-schema.sql

Read by: auth.js, backfill-speaking.js, routes/filters.js, routes/dashboard.js, routes/trainer-dashboard.js, routes/bda-app.js, routes/app-usage.js, routes/ai-analysis.js, routes/sales-dashboard.js, mcp/scope.js, mcp/tools/bda.js, frontend/src/components/AppUsagePanel.jsx

PK id. No unique constraints. Empty in emtcc, so any counsellor name comes back blank.

Counsellor_Number_UpdateSupport · screens & internals0 rowsHistory of the WhatsApp numbers the sales team has used. The student drawer reads it so it can label a chat as 'sales team' rather than a trainer. It holds numbers only, not people, because numbers move between counsellors.
ColumnTypeWhat it stores
idbigintRow id (primary key)
createdAttimestamp with time zoneCreated time
updatedAttimestamp with time zoneLast changed time
Phone_numbercharacter varyingA sales/BDA WhatsApp number. The code strips non-digits before using it.

Connected to

  • Chat History (py7a0z2w9agcvv2) — digits of Phone_number matched against the chat's business number

Written by

  • No code writes it. Defined in backend/db/em-schema.sql

Read by: routes/trainer-dashboard.js (DRAWER_SALES_NUMBERS_SQL)

PK id. Only Phone_number is read. The schema comment says it may be numeric in EP production (not verified).

SalesSupport · screens & internals0 rowsA copy of the FMOS sales log: who bought which course, and from which employee. The Rejoin screen uses it to find the original counsellor of a returning student.
ColumnTypeWhat it stores
idbigintRow id (primary key)
createdAttimestamp with time zoneCreated time
updatedAttimestamp with time zoneLast change
Student_Namecharacter varyingBuyer's name
Mobile_Numbercharacter varyingBuyer's phone (matched to Student_Details by digits)
Course_Namecharacter varyingCourse bought
Employee_Namecharacter varyingCounsellor who made the sale
Employee_IDcharacter varyingThat counsellor's employee id
Regioncharacter varyingSales region
Transaction_DatedateDate of sale

Connected to

  • Student_Details — Sales.Mobile_Number ~ Student_Details mobile (phone digits match)
  • Counsellor_Details — Sales.Employee_ID ~ Counsellor_Details.EMP_ID (not verified)

Written by

  • No code writes it (external FMOS mirror). Defined in backend/db/em-schema.sql

Read by: routes/rejoin.js (around line 302)

PK id. Many frontend files match only on the word 'Sales' in page names. Only rejoin.js reads this table. Empty in emtcc.

Sales_Update_LogSupport · screens & internals0 rowsAn audit trail of edits to payment records. A database trigger on Transaction_details writes one row for each changed column, with the old and new values. The Sales Updates dashboard shows these rows. A second trigger (trg_log_student_updates on Student_Details) also logs edits to student fields such as name, mobile, course and batch date (not Class_Mobile_Number), so rows can appear on EM when a student is edited.
ColumnTypeWhat it stores
idintegerRow id (primary key)
table_nametextTable that was edited (normally Transaction_details)
record_idintegerid of the edited row
counsellor_idintegerCounsellor whose record it is
column_nametextWhich field changed
old_valuetextValue before
new_valuetextValue after
updated_attimestamp with time zoneWhen it changed

Connected to

  • Transaction_details — Sales_Update_Log.record_id -> Transaction_details.id (when table_name='Transaction_details')
  • Counsellor_Details — Sales_Update_Log.counsellor_id -> Counsellor_Details.id

Written by

  • DB trigger trg_log_transaction_updates (function log_transaction_updates) on Transaction_details
  • backend/db/init-sales-update-log.sql (creates table, indexes and trigger)

Read by: routes/dashboard.js (sales updates endpoints), frontend/src/components/SalesUpdateDashboard.jsx / sales-dashboard/SalesUpdatesPage.jsx (via API)

PK id. Indexes on (updated_at DESC) and (counsellor_id, updated_at DESC). Empty in emtcc.

allotment_auto_stop_candidatesSupport · screens & internals0 rowsOutput of the external Trainers Allotment job: students it automatically (or someone manually) stopped, for example unpaid students after Day 1. The trainer dashboard shows these.
ColumnTypeWhat it stores
idbigintPrimary key
student_detail_idbigintStudent_Details.id
statetext'stopped' or other candidate state
stopped_bytext'auto' or 'manual'
day1_datedateStudent's Day 1 date
created_attimestamp with time zoneInsert time

Connected to

  • Student_Details — student_detail_id -> Student_Details.id

Written by

  • External Trainers Allotment job (not in this repo; the EM port is at /root/EMTrainersAllotment, not live)

Read by: routes/trainer-dashboard.js (~line 11582 LEFT JOIN)

PK id. Index idx_aasc_student (student_detail_id, state). Read-only from TCC. Empty.

allotment_payment_statusSupport · screens & internals0 rowsPer-phone payment status copied from the FMOS sales log by the Trainers Allotment job: stage, whether paid in full, amount received against the course price, team and counsellor. Trainer screens and the chat view use it for the 'Paid' badge.
ColumnTypeWhat it stores
phone10textStudent's last-10-digit phone (primary key)
stagetextPayment stage
settledbooleantrue = fully paid
receivednumericAmount received so far
course_pricenumericCourse price
teamtextSales team
counsellortextCounsellor name
updated_attimestamp with time zoneLast refresh

Connected to

  • Student_Details — phone10 = last 10 digits of student phone

Written by

  • External Trainers Allotment job (EP migration 030; not in this repo)

Read by: routes/trainer-dashboard.js (~2951, ~3980, ~11584), routes/trainer-app.js (~530), frontend/src/components/chat/ChatMode.jsx (via API)

PK phone10. In EP it may be a view; here it is recreated as a table. The code tolerates it being missing. Empty, so no Paid badges show on EM.

rejoin_logSupport · screens & internals0 rowsA record of students re-added through the admin Rejoin screen: who did it, what action ran, and which lesson day the student was on before, so a wrong reset can be undone.
ColumnTypeWhat it stores
idbigintPrimary key
student_idbigintStudent_Details.id (unique, one row per student)
progress_idbigintStudent_Progress row created or reset
created_sdbooleantrue = a new Student_Details row was created
added_bycharacter varyingAdmin who did it
added_attimestamp with time zoneWhen
actioncharacter varyingWhich rejoin action ran (reset / enrol ...)
prev_daycharacter varyingLesson day before the reset

Connected to

  • Student_Details — rejoin_log.student_id -> Student_Details.id
  • Student_Progress — rejoin_log.progress_id -> Student_Progress.id
  • Sales — rejoin.js looks up counsellor in Sales by phone

Written by

  • backend/routes/rejoin.js (logRejoin upsert ON CONFLICT (student_id); DELETE on remove; creates table at boot)

Read by: routes/rejoin.js (/api/admin/rejoin/added), frontend/src/components/RejoinPanel.jsx (via API)

PK id. UNIQUE (student_id), so a re-rejoin overwrites the row. Empty.

999_Student_ProgressEP-only · unused on EM0 rowsDay-by-day progress for EP's Kannada '999' course, a cheap course with no trainer that runs on links. It is one row per enrolled student and has the same shape as Speaking_Student_Progress. EM has no 999 course. The EM engine's 'unsupported' hook rejects 999 actions.
ColumnTypeWhat it stores
idbigintProgress row id (primary key)
createdAttimestamp with time zoneEnrolment time
updatedAttimestamp with time zoneLast change
createdByIdbigintNocoBase creator (unused)
updatedByIdbigintNocoBase editor (unused)
student_idbigintStudent_Details.id
current_lesson_daycharacter varyingLesson day the student is on (text number)
Progress_statecharacter varyingState such as Ready / Waiting_start / Waiting_completed / follow_up_sent / Completed
Follow_up_countintegerHow many reminders were sent
Next_Followup_Attimestamp with time zoneWhen the next reminder is due
is_stopbooleantrue = paused/stopped
Next_Start_DatedateDate to resume or start

Connected to

  • Student_Details — 999_Student_Progress.student_id -> Student_Details.id
  • Student_Day_logs — Student_Day_logs rows with Lesson_Type='Kannada'

Written by

  • EP n8n Kannada workflows (not in EM)
  • backend/routes/trainer-dashboard.js (manual day/state edits)
  • backend/scripts/fixes/revert-19907-accidental-advance-2026-09-26.sql (one-off EP fix)

Read by: routes/trainer-dashboard.js, routes/messages.js, n8n/999-link/monitor-kannada-link.js

PK id. No unique constraint on student_id. On EP, the n8n schedule moved rows Ready -> Waiting_start -> Waiting_completed and advanced current_lesson_day each day. EM has no such sender, so these rows are never created on EM. Empty.

Bot_ConfigEP-only · unused on EM0 rowsMaps each sales (BDA) WhatsApp number to the counsellor who owns it. The login code and the sales dashboards use it to work out which counsellor a sales number belongs to. It does NOT hold the EM lesson-sending number: the EM engine keeps its bot number(s) in the table public.em_wa_numbers, with tokens encrypted (AES-256-GCM).
ColumnTypeWhat it stores
idbigintRow id (primary key)
createdAttimestamp with time zoneWhen the row was created (NocoBase audit column)
updatedAttimestamp with time zoneWhen the row was last changed
createdByIdbigintNocoBase user who created it (unused here)
updatedByIdbigintNocoBase user who last changed it (unused here)
BDA_WA_Numbercharacter varyingWhatsApp number a sales counsellor (BDA) uses to chat with leads
counsellor_idbigintThe counsellor who owns this number (points to Counsellor_Details.id)

Connected to

  • Counsellor_Details — Bot_Config.counsellor_id -> Counsellor_Details.id
  • Student_Details — Student_Details.counselor_id -> Bot_Config.counsellor_id (auth.js line ~979, finds the BDA number for a student)
  • Lead_Info / Transaction_details — BDA_WA_Number text match

Written by

  • No code in the repo writes it. In EP it was filled by hand in NocoBase. Defined in backend/db/em-schema.sql

Read by: auth.js, routes/filters.js, routes/dashboard.js, routes/sales-dashboard.js, routes/forge-agent.js, routes/ai-analysis.js, routes/app-usage.js, mcp/scope.js, mcp/tools/bda.js, demo-mask.js, frontend/src/components/FilterBar.jsx, frontend/src/components/AppUsagePanel.jsx

PK id. There is no unique constraint on BDA_WA_Number and no real FK. The table is empty in emtcc, so BDA/sales filters return nothing on EM. These are sales numbers, not lesson numbers.

Lead_Event_LogEP-only · unused on EM0 rowsAn event history of each lead moving through the sales funnel, one row per stage reached. The funnel reports count these rows.
ColumnTypeWhat it stores
idbigintEvent id (primary key)
createdAttimestamp with time zoneWhen the stage was reached
updatedAttimestamp with time zoneLast change
createdByIdbigintNocoBase creator (unused)
updatedByIdbigintNocoBase editor (unused)
WA_Numbercharacter varyingLead's WhatsApp number
BDA_WA_Numbercharacter varyingSales number involved
Stagecharacter varyingStage name: FIRST_MESSAGE / VOUCHER_SENT / VOUCHER_REDEEMED / PURCHASED ...

Connected to

  • Lead_Info — Lead_Event_Log.WA_Number = Lead_Info.WA_Number

Written by

  • No code in the repo writes it (EP's n8n sales flow). Defined in backend/db/em-schema.sql

Read by: routes/sales-dashboard.js, routes/ai-analysis.js, routes/forge-agent.js, mcp/tools/leads.js, mcp/tools/funnel.js, mcp/tools/bda.js

PK id. Append-only log. Empty in emtcc.

Lead_InfoEP-only · unused on EM0 rowsEP's sales lead list: one row per prospect, with the sales number handling them, their current funnel stage and profile details. It feeds the sales dashboard, the AI analysis and the MCP funnel/lead tools. The EM lesson system does not use it.
ColumnTypeWhat it stores
idbigintLead id (primary key)
createdAttimestamp with time zoneWhen the lead first came in
updatedAttimestamp with time zoneLast change
createdByIdbigintNocoBase creator (unused)
updatedByIdbigintNocoBase editor (unused)
Namecharacter varyingLead's name
WA_Numbercharacter varyingLead's WhatsApp number
BDA_WA_Numbercharacter varyingSales number chatting with the lead
Current_Stagecharacter varyingCurrent funnel stage (FIRST_MESSAGE, VOUCHER_SENT, PURCHASED, ...)
Sourcecharacter varyingWhere the lead came from (ad, organic, ...)
Citycharacter varyingCity
Agecharacter varyingAge as text
levelcharacter varyingEnglish level (lower-case duplicate column)
Levelcharacter varyingEnglish level (capitalised duplicate column, inherited quirk)
Engagementcharacter varyingEngagement rating
Batch_DatedatePlanned batch start date
Professioncharacter varyingOccupation

Connected to

  • Lead_Event_Log — Lead_Info.WA_Number = Lead_Event_Log.WA_Number (and BDA_WA_Number)
  • Bot_Config — Lead_Info.BDA_WA_Number = Bot_Config.BDA_WA_Number

Written by

  • No code in the repo writes it (EP's n8n sales bot wrote it). Defined in backend/db/em-schema.sql

Read by: routes/sales-dashboard.js, routes/ai-analysis.js, routes/forge-agent.js, mcp/scope.js, mcp/tools/funnel.js, mcp/tools/leads.js, mcp/tools/dashboard.js, mcp/tools/bda.js

PK id. There are two level columns ('level' and 'Level'). Empty in emtcc.

Tamil_999_Student_ProgressEP-only · unused on EM0 rowsThe Tamil version of the trainer-less 999 course progress table. It works the same way as the Kannada one. It is EP-only.
ColumnTypeWhat it stores
idbigintPrimary key
createdAttimestamp with time zoneEnrolment time
updatedAttimestamp with time zoneLast change
createdByIdbigintunused
updatedByIdbigintunused
student_idbigintStudent_Details.id
current_lesson_daycharacter varyingCurrent lesson day
Progress_statecharacter varyingLesson state
Follow_up_countintegerReminders sent
Next_Followup_Attimestamp with time zoneNext reminder due
is_stopbooleanStopped flag
Next_Start_DatedateResume date

Connected to

  • Student_Details — Tamil_999_Student_Progress.student_id -> Student_Details.id
  • Student_Day_logs — Lesson_Type='Tamil'

Written by

  • EP n8n Tamil workflows (not in EM)
  • backend/routes/trainer-dashboard.js (~line 5677: manual day/state edit, sets Next_Followup_At +2 days for follow_up/stuck, increments Follow_up_count for stuck)

Read by: routes/trainer-dashboard.js, routes/messages.js, n8n/999-link/monitor-tamil-link.js

PK id only. Empty.

Telugu_999_Student_ProgressEP-only · unused on EM0 rowsThe Telugu version of the trainer-less 999 course progress table. It is EP-only.
ColumnTypeWhat it stores
idbigintPrimary key
createdAttimestamp with time zoneEnrolment time
updatedAttimestamp with time zoneLast change
createdByIdbigintunused
updatedByIdbigintunused
student_idbigintStudent_Details.id
current_lesson_daycharacter varyingCurrent lesson day
Progress_statecharacter varyingLesson state
Follow_up_countintegerReminders sent
Next_Followup_Attimestamp with time zoneNext reminder due
is_stopbooleanStopped flag
Next_Start_DatedateResume date

Connected to

  • Student_Details — Telugu_999_Student_Progress.student_id -> Student_Details.id
  • telugu999_link_device — student's phone (last 10 digits) = telugu999_link_device.phone
  • Student_Day_logs — Lesson_Type='Telugu999'

Written by

  • EP n8n Telugu workflows (not in EM)
  • backend/routes/trainer-dashboard.js (~line 6077 manual edit)

Read by: routes/trainer-dashboard.js, routes/messages.js

PK id only. Empty.

Transaction_detailsEP-only · unused on EM0 rowsEP's payment records: one row per payment received, with amount, mode, account, status and a receipt image link, tied to the student, course and counsellor. The sales revenue dashboards use it.
ColumnTypeWhat it stores
idbigintPayment row id (primary key)
createdAttimestamp with time zoneCreated time
updatedAttimestamp with time zoneLast change
createdByIdbigintNocoBase creator (unused)
updatedByIdbigintNocoBase editor (unused)
Payment_statuscharacter varyingFull-Paid / Partial etc. (trimmed in queries)
Mode_of_paymentcharacter varyingUPI, card, bank transfer ...
Received_AmountnumericAmount received (summed for revenue)
Credited_Accountcharacter varyingWhich company account got the money
Transaction_DatedatePayment date
Transaction_IDcharacter varyingBank/UPI reference
Media_URLtextLink to the payment screenshot
Student_Typecharacter varyingNew / renewal type of buyer (not verified)
Execution_IDcharacter varyingn8n execution that created the row
BDA_WA_Numbercharacter varyingSales number that closed it
student_idbigintStudent_Details.id
course_idbigintCourse_Details.id
counsellor_idbigintCounsellor_Details.id

Connected to

  • Student_Details — Transaction_details.student_id -> Student_Details.id
  • Course_Details — Transaction_details.course_id -> Course_Details.id
  • Counsellor_Details — Transaction_details.counsellor_id -> Counsellor_Details.id
  • Sales_Update_Log — trigger trg_log_transaction_updates writes Sales_Update_Log on update

Written by

  • No app code writes it (EP n8n sales-log flow). Defined in backend/db/em-schema.sql and backend/db/init-sales-update-log.sql

Read by: routes/dashboard.js, frontend/src/components/sales-dashboard/SalesRecentPage.jsx (via API)

PK id. Has the trigger trg_log_transaction_updates. Empty in emtcc.

revision_meet_send_logEP-only · unused on EM0 rowsA record of weekly revision Google Meet messages, both reminders and meet links, sent to students. It stops the same message going out twice in a week and powers the Revision Report.
ColumnTypeWhat it stores
idbigintPrimary key
wa_numbercharacter varyingStudent WhatsApp number
meetsmallintWhich revision meet (1-4; 4 = finisher meet)
phasecharacter varying'reminder' or 'meetlink'
levelcharacter varyingStudent level group
week_startdateWeek the send belongs to
sent_attimestamp with time zoneSend time

Connected to

  • revision_meet_toggles — meet number
  • Student_Details — wa_number ~ student phone

Written by

  • EP n8n crons: backend/n8n/send-revision-reminder-cron.json, send-revision-meetlink-cron.json, send-revision-finisher-*-cron.json (INSERT ... ON CONFLICT DO NOTHING)
  • backend/routes/revision-meet.js (creates table at boot)

Read by: routes/revision-meet.js (report/weeks/history), the same n8n crons (skip already-sent), frontend/src/components/RevisionReportPanel.jsx (via API)

PK id. UNIQUE idx_revsend_uniq (wa_number, meet, phase, week_start). EM has no n8n, so nothing sends these on EM. Empty.

revision_meet_togglesEP-only · unused on EM0 rowsAn on/off switch for each revision meet, set by admins on the Revision screen. The n8n senders check it.
ColumnTypeWhat it stores
meetsmallintMeet number (primary key)
enabledbooleanOn or off
updated_attimestamp with time zoneWhen changed
updated_bycharacter varyingAdmin who changed it

Connected to

  • revision_meet_send_log — meet

Written by

  • backend/routes/revision-meet.js (PUT /api/revision-meet/toggles; creates table at boot)

Read by: routes/revision-meet.js (GET toggles), EP n8n revision crons (not verified)

PK meet. Empty.

speaking_day_marksEP-only · unused on EM0 rowsA trainer's 'speaking call done today' marks. A trainer looks a student up by phone and marks the class. A row is recorded for every mark. The lesson day moves forward only for phones listed in speaking_mark_enabled (or when the course-wide switch is on).
ColumnTypeWhat it stores
progress_idbigintSpeaking_Student_Progress.id that was marked
mark_datedateIST date of the mark
student_idbigintStudent_Details.id
lesson_daytextLesson day at the time of marking
trainer_watextTrainer's WhatsApp number
trainer_idintegerTrainers_info.id
ownedbooleanWhether the student is that trainer's own student
advancedbooleantrue = the mark also advanced the day; false = recorded only
created_attimestamp with time zoneInsert time

Connected to

  • Speaking_Student_Progress — speaking_day_marks.progress_id -> Speaking_Student_Progress.id
  • Student_Details — student_id -> Student_Details.id
  • Trainers_info — trainer_id -> Trainers_info.id

Written by

  • backend/routes/speaking-mark.js (POST /api/trainer/speaking-mark/:progressId does INSERT ... ON CONFLICT (progress_id, mark_date) DO NOTHING; DELETE undoes it; table created lazily)

Read by: routes/speaking-mark.js (speaking-lookup)

PK (progress_id, mark_date), so at most one mark per student per day. When advanced, it also writes a Student_Day_logs speaking entry via utils/stop-both insertSpeakingDayLog. Whether EM uses speaking is not verified. Empty. Inactive on EM: EM has no Speaking course.

speaking_mark_enabledEP-only · unused on EM0 rowsA test allowlist of phones for which a trainer's speaking mark actually moves the lesson day forward. For everyone else the mark is only recorded, so the Start tap and the mark don't both advance the day.
ColumnTypeWhat it stores
phonetextStudent phone (primary key)
notetextWhy it was added
created_attimestamp with time zoneWhen added

Connected to

  • Student_Details — phone ~ student phone digits

Written by

  • Manual SQL / admin only (no insert in code found; table created lazily by backend/routes/speaking-mark.js)

Read by: routes/speaking-mark.js

PK phone. Empty. Inactive on EM: EM has no Speaking course.

Found while mapping

Things to fix in how data flows

IssueEffect
Replies typed in the "EM Trainers" chat section go out from the lesson-bot numberThat section has no number of its own in the config, so the engine falls back to the bot. Trainers should reply from their own phones until this is fixed.
Template name and language are test values (em_day_test, ml)Must be replaced with the real approved template before the first send
Any reply from a waiting student starts the lessonA plain "hi" releases the day's lesson (setting "any reply starts the lesson" is on)
A phone with two active enrolmentsMatching is on the last 10 digits, so one tap releases both
Student linked only to Sunday trainersNever sent anything (the daily job requires a weekday trainer)
"Send Today's Class" logs a completed day eventReports count template-only sends as manual class sends
Footer numbersShown as 10 digits without 91
Start-tap phone lookupNot covered by an index; fine now, should get one before thousands of students (server Rule #3)