From 0f23453f91513a442c840485e2fe534ff2d05380 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Sun, 19 Jul 2026 13:50:12 +0100 Subject: [PATCH] fix(api): use admin app URL for teacher auto-enroll email links (#771) * fix(api): use admin app URL for teacher auto-enroll links Teacher payment-request emails now build course and grant-access URLs with getAppBaseUrl() instead of getDashboardBaseUrl(org), so links point at app.classroomio.com rather than a custom or tenant domain. Document the teacher vs student email link rule in AGENTS.md. Co-authored-by: Rotimi Best * docs: surface email link URL rules where emails are wired Document getAppBaseUrl vs getDashboardBaseUrl in dashboard-url.ts, packages/email/README.md, enqueueTransactionalEmail JSDoc, and AGENTS.md so staff vs learner hosts are discoverable when building email links. Co-authored-by: Rotimi Best --------- Co-authored-by: Cursor Agent Co-authored-by: Rotimi Best --- AGENTS.md | 8 +++++ .../src/services/course/payment-request.ts | 13 +++----- apps/api/src/services/jobs/email-jobs.ts | 5 ++++ packages/core/src/config/dashboard-url.ts | 30 +++++++++++++++++-- packages/email/README.md | 23 ++++++++++++++ 5 files changed, 67 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f47f2431..336043e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -440,6 +440,14 @@ Every transactional email in `packages/email/src/emails` is one of two kinds — Rule of thumb: if the recipient is being addressed **as a ClassroomIO customer** (account/billing/limits), it's a system email → ClassroomIO branding. If they're addressed **as a member of a specific org**, it's org-branded. +### Email link URLs + +Links embedded in transactional emails must use the correct dashboard host. Templates only receive pre-built URLs — callers build them in API services before `enqueueTransactionalEmail`. See `packages/email/README.md` § Email link URLs. + + +- **Teacher/tutor/admin dashboard actions** (course management, grading, team invites, auto-enroll): build URLs with `getAppBaseUrl()` from `@cio/core/config/dashboard-url`. This resolves to the admin app (`app.classroomio.com` in cloud, or `DASHBOARD_ORIGIN` / localhost in dev). Do **not** pass org `customDomain` or tenant `siteName` — staff sign in on the admin app, not the org public site. +- **Student/learner-facing links** (course enroll, org audience invites, login): build URLs with `getDashboardBaseUrl(org)` so links land on the org's public site (verified custom domain, tenant subdomain, or platform fallback). + ## Best Practices Summary ### ✅ DO diff --git a/apps/api/src/services/course/payment-request.ts b/apps/api/src/services/course/payment-request.ts index ffe1ac45..182735f4 100644 --- a/apps/api/src/services/course/payment-request.ts +++ b/apps/api/src/services/course/payment-request.ts @@ -5,7 +5,7 @@ import { getCourseWithOrgData } from '@cio/db/queries/course'; import { buildEmailFromName, buildEmailBranding } from '@cio/email'; import { enqueueTransactionalEmail } from '@api/services/jobs'; import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics'; -import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url'; +import { getAppBaseUrl } from '@cio/core/config/dashboard-url'; export interface PaymentRequestData { courseId: string; @@ -48,14 +48,9 @@ export async function createPaymentRequest(data: PaymentRequestData) { const teacherEmail = teacherResult[0].email; - const dashboardOrg = { - siteName: course.orgSiteName, - customDomain: course.orgCustomDomain, - isCustomDomainVerified: course.orgIsCustomDomainVerified - }; - const dashboardBaseUrl = getDashboardBaseUrl(dashboardOrg); - const courseUrl = `${dashboardBaseUrl}/courses/${data.courseId}`; - const autoEnrollUrl = `${dashboardBaseUrl}/courses/${data.courseId}/people?grantAccess=${encodeURIComponent(data.studentEmail)}`; + const appBaseUrl = getAppBaseUrl(); + const courseUrl = `${appBaseUrl}/courses/${data.courseId}`; + const autoEnrollUrl = `${appBaseUrl}/courses/${data.courseId}/people?grantAccess=${encodeURIComponent(data.studentEmail)}`; try { await enqueueTransactionalEmail('teacherStudentBuyRequest', { diff --git a/apps/api/src/services/jobs/email-jobs.ts b/apps/api/src/services/jobs/email-jobs.ts index eb14f51c..07c23946 100644 --- a/apps/api/src/services/jobs/email-jobs.ts +++ b/apps/api/src/services/jobs/email-jobs.ts @@ -59,6 +59,11 @@ function recipientKey(base: string | undefined, recipient: string, total: number * against the template's Zod schema up front so bad payloads fail in the * domain handler instead of silently inside the worker. * + * **Link fields** (`courseUrl`, `autoEnrollUrl`, `loginUrl`, `inviteUrl`, etc.) + * must be built by the caller before enqueueing. Use `getAppBaseUrl()` for + * teacher/tutor/admin dashboard links and `getDashboardBaseUrl(org)` for + * student/learner links — see `packages/email/README.md` § Email link URLs. + * * BullMQ tracks send state, retries, and failure history — no DB ledger is * written. Final failures are recorded in `dead_letter_job` by the worker * for operator triage. diff --git a/packages/core/src/config/dashboard-url.ts b/packages/core/src/config/dashboard-url.ts index ef973902..62252c40 100644 --- a/packages/core/src/config/dashboard-url.ts +++ b/packages/core/src/config/dashboard-url.ts @@ -2,6 +2,21 @@ import { TENANT_ROOT_DOMAIN, BRAND_ROOT_DOMAIN } from '@cio/utils/constants/doma import { env } from './env'; +/** + * Dashboard host helpers for links embedded in emails and other outbound URLs. + * + * Email templates only receive pre-built URLs in their `fields` — callers in + * `apps/api` (or other services) choose the host here before enqueueing mail. + * + * - **Student/learner links** (course enroll, audience invites, login): + * `getDashboardBaseUrl(org)` — org custom domain, tenant subdomain, or fallback. + * - **Teacher/tutor/admin dashboard actions** (course management, grading, team + * invites, auto-enroll): `getAppBaseUrl()` — admin app (`app.classroomio.com` + * in cloud). Do not pass org `customDomain` or tenant `siteName`. + * + * See `packages/email/README.md` and `AGENTS.md` § Email link URLs. + */ + /** * Minimal org shape consumed by `getDashboardBaseUrl`. Accepts the three * fields that decide which host the org's URLs live on. All optional so @@ -14,8 +29,10 @@ export type DashboardOrg = { }; /** - * Returns the base URL for the dashboard app, used for invite links, login - * URLs, course URLs, and email-embedded links. + * Returns the learner-facing dashboard base URL for an org. + * + * Use for **student/learner email links** (course enroll, audience invites, + * login) so links land on the org's public site. * * Resolution order (highest precedence first): * 1. Org's verified `customDomain` → `https://` @@ -26,6 +43,8 @@ export type DashboardOrg = { * * `customDomain` wins over `DASHBOARD_ORIGIN` because per-org BYOD must * override the platform-wide self-hosted dashboard host for that org's URLs. + * + * For teacher/tutor/admin dashboard actions in email, use `getAppBaseUrl()`. */ export function getDashboardBaseUrl(org?: DashboardOrg): string { if (org?.customDomain && org.isCustomDomainVerified) { @@ -49,7 +68,12 @@ export function getDashboardBaseUrl(org?: DashboardOrg): string { /** * Returns the admin dashboard origin (`app.classroomio.com` in cloud). - * Use for team-member invite links (admin/tutor) — not student-facing URLs. + * + * Use for **teacher/tutor/admin email links** — course management, grading, + * team invites, auto-enroll, and any other staff dashboard action. Staff sign + * in on the admin app, not the org public site. + * + * Do not use for student/learner-facing links; use `getDashboardBaseUrl(org)`. */ export function getAppBaseUrl(): string { return getDashboardBaseUrl(); diff --git a/packages/email/README.md b/packages/email/README.md index b961d537..a60bcaaa 100644 --- a/packages/email/README.md +++ b/packages/email/README.md @@ -46,6 +46,29 @@ await send('forgotPassword', { }); ``` +## Email link URLs + +Email templates receive fully-built URLs in their `fields` — templates do **not** choose the host. Callers in `apps/api` (or other services) must build links before calling `enqueueTransactionalEmail` or `send`. + +Import helpers from `@cio/core/config/dashboard-url`: + +| Recipient | Helper | Use for | +| --- | --- | --- | +| Teacher, tutor, admin | `getAppBaseUrl()` | Course management, grading, team invites, auto-enroll | +| Student, learner | `getDashboardBaseUrl(org)` | Course enroll, audience invites, login | + +```typescript +import { getAppBaseUrl, getDashboardBaseUrl } from '@cio/core/config/dashboard-url'; + +// Staff dashboard action (admin app — not org custom domain) +const autoEnrollUrl = `${getAppBaseUrl()}/courses/${courseId}/people?grantAccess=${encodeURIComponent(email)}`; + +// Learner-facing invite (org public site) +const inviteUrl = `${getDashboardBaseUrl(org)}/invite/${encodeURIComponent(token)}`; +``` + +See also `AGENTS.md` § Email link URLs. + ## Creating Email Templates ### Basic Example