Files
classroomio/apps/api/src/services/course/payment-request.ts
T
0f23453f91 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>
2026-07-19 13:50:12 +01:00

113 lines
3.5 KiB
TypeScript

import { AppError, ErrorCodes } from '@api/utils/errors';
import { getCourseTeachers } from '@cio/db/queries/course/people';
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 { getAppBaseUrl } from '@cio/core/config/dashboard-url';
export interface PaymentRequestData {
courseId: string;
studentEmail: string;
studentFullname: string;
}
/**
* Creates a payment request and sends emails to teacher and student
* @param data Payment request data
*/
export async function createPaymentRequest(data: PaymentRequestData) {
try {
// Get course and organization data
const course = await getCourseWithOrgData(data.courseId);
if (!course) {
throw new AppError('Course not found', ErrorCodes.NOT_FOUND, 404);
}
const courseName = course.courseTitle || '';
const orgName = course.orgName || 'ClassroomIO';
const branding = buildEmailBranding({
name: course.orgName,
avatarUrl: course.orgAvatarUrl,
theme: course.orgTheme
});
const groupId = course.groupId;
if (!groupId) {
throw new AppError('Course group not found', ErrorCodes.NOT_FOUND, 404);
}
// Get first teacher (TUTOR or ADMIN) email
const teacherResult = await getCourseTeachers({ groupId, limit: 1 });
if (teacherResult.length === 0 || !teacherResult[0]?.email) {
throw new AppError('No teacher found for this course', ErrorCodes.NOT_FOUND, 404);
}
const teacherEmail = teacherResult[0].email;
const appBaseUrl = getAppBaseUrl();
const courseUrl = `${appBaseUrl}/courses/${data.courseId}`;
const autoEnrollUrl = `${appBaseUrl}/courses/${data.courseId}/people?grantAccess=${encodeURIComponent(data.studentEmail)}`;
try {
await enqueueTransactionalEmail('teacherStudentBuyRequest', {
to: teacherEmail,
fields: {
courseName,
studentEmail: data.studentEmail,
studentFullname: data.studentFullname,
courseUrl,
autoEnrollUrl,
branding
},
from: buildEmailFromName('ClassroomIO'),
idempotencyKey: `payment-request:teacher:${data.courseId}:${data.studentEmail}`
});
} catch (emailError) {
console.error('Failed to enqueue teacher buy request email:', emailError);
}
try {
await enqueueTransactionalEmail('studentProvePayment', {
to: data.studentEmail,
fields: {
courseName,
teacherEmail,
studentFullname: data.studentFullname,
orgName,
branding
},
from: buildEmailFromName(`${orgName} - ClassroomIO`),
replyTo: teacherEmail,
idempotencyKey: `payment-request:student:${data.courseId}:${data.studentEmail}`
});
} catch (emailError) {
console.error('Failed to enqueue student payment proof email:', emailError);
}
trackServerEvent({
eventType: SERVER_EVENTS.ENROLLMENT_STARTED,
courseId: data.courseId,
props: { path: 'payment-request', orgName }
});
return {
success: true,
courseName,
teacherEmail
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to create payment request',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}