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:
co-authored by
Rotimi Best
Cursor Agent
parent
e09b3caa8a
commit
0f23453f91
@@ -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', {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user