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 <[email protected]> * 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 <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Rotimi Best <[email protected]>
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.
|
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
|
## Best Practices Summary
|
||||||
|
|
||||||
### ✅ DO
|
### ✅ DO
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getCourseWithOrgData } from '@cio/db/queries/course';
|
|||||||
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
|
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
|
||||||
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||||
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
|
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 {
|
export interface PaymentRequestData {
|
||||||
courseId: string;
|
courseId: string;
|
||||||
@@ -48,14 +48,9 @@ export async function createPaymentRequest(data: PaymentRequestData) {
|
|||||||
|
|
||||||
const teacherEmail = teacherResult[0].email;
|
const teacherEmail = teacherResult[0].email;
|
||||||
|
|
||||||
const dashboardOrg = {
|
const appBaseUrl = getAppBaseUrl();
|
||||||
siteName: course.orgSiteName,
|
const courseUrl = `${appBaseUrl}/courses/${data.courseId}`;
|
||||||
customDomain: course.orgCustomDomain,
|
const autoEnrollUrl = `${appBaseUrl}/courses/${data.courseId}/people?grantAccess=${encodeURIComponent(data.studentEmail)}`;
|
||||||
isCustomDomainVerified: course.orgIsCustomDomainVerified
|
|
||||||
};
|
|
||||||
const dashboardBaseUrl = getDashboardBaseUrl(dashboardOrg);
|
|
||||||
const courseUrl = `${dashboardBaseUrl}/courses/${data.courseId}`;
|
|
||||||
const autoEnrollUrl = `${dashboardBaseUrl}/courses/${data.courseId}/people?grantAccess=${encodeURIComponent(data.studentEmail)}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await enqueueTransactionalEmail('teacherStudentBuyRequest', {
|
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
|
* against the template's Zod schema up front so bad payloads fail in the
|
||||||
* domain handler instead of silently inside the worker.
|
* 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
|
* BullMQ tracks send state, retries, and failure history — no DB ledger is
|
||||||
* written. Final failures are recorded in `dead_letter_job` by the worker
|
* written. Final failures are recorded in `dead_letter_job` by the worker
|
||||||
* for operator triage.
|
* for operator triage.
|
||||||
|
|||||||
@@ -2,6 +2,21 @@ import { TENANT_ROOT_DOMAIN, BRAND_ROOT_DOMAIN } from '@cio/utils/constants/doma
|
|||||||
|
|
||||||
import { env } from './env';
|
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
|
* Minimal org shape consumed by `getDashboardBaseUrl`. Accepts the three
|
||||||
* fields that decide which host the org's URLs live on. All optional so
|
* 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
|
* Returns the learner-facing dashboard base URL for an org.
|
||||||
* URLs, course URLs, and email-embedded links.
|
*
|
||||||
|
* 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):
|
* Resolution order (highest precedence first):
|
||||||
* 1. Org's verified `customDomain` → `https://<customDomain>`
|
* 1. Org's verified `customDomain` → `https://<customDomain>`
|
||||||
@@ -26,6 +43,8 @@ export type DashboardOrg = {
|
|||||||
*
|
*
|
||||||
* `customDomain` wins over `DASHBOARD_ORIGIN` because per-org BYOD must
|
* `customDomain` wins over `DASHBOARD_ORIGIN` because per-org BYOD must
|
||||||
* override the platform-wide self-hosted dashboard host for that org's URLs.
|
* 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 {
|
export function getDashboardBaseUrl(org?: DashboardOrg): string {
|
||||||
if (org?.customDomain && org.isCustomDomainVerified) {
|
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).
|
* 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 {
|
export function getAppBaseUrl(): string {
|
||||||
return getDashboardBaseUrl();
|
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
|
## Creating Email Templates
|
||||||
|
|
||||||
### Basic Example
|
### Basic Example
|
||||||
|
|||||||
Reference in New Issue
Block a user