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 <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2026-07-19 13:50:12 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent e09b3caa8a
commit 0f23453f91
5 changed files with 67 additions and 12 deletions
+8
View File
@@ -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
@@ -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', {
+5
View File
@@ -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.
+27 -3
View File
@@ -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://<customDomain>`
@@ -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();
+23
View File
@@ -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