* feat(billing): enforce free-plan student cap across the app
Free (BASIC) plan orgs were able to exceed the advertised 20-student
limit because the cap existed only as dashboard cosmetic copy with no
server-side enforcement.
Add a single source of truth (getStudentLimit / STUDENT_LIMITS) and a
central guard (assertStudentCapacityOrThrow) wired into every path that
creates a new student-role org member: self-enrolment, course-invite
acceptance, bulk audience import, and org-invite acceptance. The guard
throws UPGRADE_REQUIRED (403) and notifies org admins by email (throttled
to once per org per day) when the limit is hit.
Surface the limit in the UI: disable the audience import button and the
course new-student invite section with upgrade banners for admins, and
show a "contact your administrator" message on the public enrol page for
students (who cannot upgrade). Expose a studentLimitReached flag from the
public course endpoint so the enrol button can be disabled proactively.
Add a one-time (manual, non-cron) db script,
db:notify-students-over-limit, to email admins of orgs already over the
limit at rollout.
* feat(billing): guard remaining student-creating paths against the cap
The initial cap enforcement missed several service entry points that also
create a new student-role org member. Add the capacity guard to:
- autoEnrollStudent (public org self-join on a tenant site)
- cohort enrollment (bulk group enrollment and single member accept)
- ensureProgramCourseAccess (program/cohort-granted course access)
For the cohort and program paths, only genuinely new members are counted
(existing org members are skipped) so re-enrolling current students is
never falsely blocked. The core path enforces via the shared limit check;
it throws UPGRADE_REQUIRED without the admin email since the queue lives
in the api layer.
* fix(billing): don't show cap block to existing org members on enroll page
The public course-by-slug endpoint is unauthenticated, so the enroll
page's studentLimitReached flag is org-wide and can't tell whether the
current viewer is already an org member. An existing student of a full
BASIC org would see Join disabled even though the backend correctly lets
existing members enroll without consuming a new seat.
Only pre-block anonymous visitors (would-be new joiners). Logged-in users
are governed by the membership-aware backend, which allows existing
members and returns UPGRADE_REQUIRED (surfaced via the enroll onError
mapping) for genuinely new students.
* feat(billing): deliver org plan-limits via /account with an extensible registry
Replace the ad-hoc, per-surface audience fetches (each calling getOrgAudience
just to read a count) with a single, future-proof delivery of org usage +
limits on the /account payload, gated to admin/tutor members (students never
receive limit data; a plain COUNT, no student rows).
Model it generically so future org limits (courses, etc.) drop in without
reshaping the payload or re-plumbing the frontend:
- New packages/utils plan-limits registry: PLAN_LIMIT_RESOURCES, PLAN_LIMITS
(reuses STUDENT_LIMITS), getPlanLimit, and a JSON-safe ResourceUsage /
OrgUsageLimits shape with isResourceLimitReached / resourceLimitRemaining.
- getAccountData attaches a per-org `limits` map for admin/tutor orgs.
- Store selectors currentOrgLimits / studentUsage / isStudentLimitReached;
sidebars, upgrade card, People pages, and invite modals read these instead
of fetching audience rows.
Bring cohort surfaces to parity with course: cohort People page banner and
cohort sidebar People upgrade icon, matching the course equivalents.
Also: close the upgrade modal that opened over the invite modal (drop the
`add` param via openUpgradeModalOver), redesign the sidebar upgrade card
(progress toward the student limit), add a Progress `variant`, and document
the no-complex-expressions-in-object-literals rule in AGENTS.md.
* refactor(billing): address PR review on the limits work
- Dedupe new student profile IDs before the cohort seat check so duplicate
batch entries can't overcount against the cap (Greptile P1).
- Drive the org-sidebar upgrade indicator from a dynamic nav-item `upgrade`
flag (audience config declares `upgradeResource: 'students'`; groups set it
from a `limitsReached` map) instead of a hardcoded path check.
- Collapse the audience Import button into a single button with ternary
`disabled`/`href`.
- Simplify the notify-students-over-limit upgrade link to app.classroomio.com.
- Drop the comment on the org `limits` field.
- Add .greptile/rules.md documenting the manual one-time db/src/scripts
convention (operator-controlled reruns) so idempotency isn't reflagged.
* fix(billing): name the org in the enroll capacity message
Change the enroll-page student-limit message from "This course has reached
its student capacity" to "{orgName} has reached its student capacity", since
the cap is org-wide, not course-specific. Pass orgName from the loaded org on
the page and from the currentOrg store in the enroll onError snackbar.
* style(billing): use PremiumIcon for the Authentication upgrade indicator
Replace the RocketIcon on paid settings sub-items (e.g. Authentication) with
the PremiumIcon used on the Audience item, right-aligned (ml-auto) to match.
753 lines
24 KiB
TypeScript
753 lines
24 KiB
TypeScript
import { AppError, ErrorCodes } from '@api/utils/errors';
|
|
import {
|
|
checkEmailsExistInOrg,
|
|
claimPendingOrganizationInvite,
|
|
createOrganizationInvite,
|
|
createOrganizationInviteAudit,
|
|
createOrganizationMember,
|
|
createOrganizationMembers,
|
|
createLinkInvite,
|
|
getActivePendingOrgInviteForEmail,
|
|
getOrganizationById,
|
|
getOrganizationInviteByTokenHash,
|
|
getOrgLinkInvite,
|
|
getOrgLinkInviteWithOrg,
|
|
revokeActiveOrganizationInvitesByEmails,
|
|
selectOrganizationInviteWithOrgByInviteId,
|
|
selectOrganizationInviteWithOrgByTokenHash,
|
|
selectOrganizationMemberByOrgAndNormalizedEmail,
|
|
selectOrganizationMemberByOrgAndProfile,
|
|
setLinkInviteRevoked,
|
|
updateOrganizationMemberById
|
|
} from '@cio/db/queries/organization';
|
|
import { getCourseGroupIds } from '@cio/db/queries/course';
|
|
import { enrollUsersInCourseGroups } from '@cio/db/queries/group';
|
|
import { addCohortMember, getExistingCohortMembers } from '@cio/db/queries/cohort';
|
|
|
|
import { ROLE } from '@cio/utils/constants';
|
|
import type { TNewOrganizationInviteAudit } from '@db/types';
|
|
import crypto from 'node:crypto';
|
|
import { db, type DbOrTxClient } from '@cio/db/drizzle';
|
|
import { assertStudentCapacityOrThrow } from './student-limit';
|
|
import { getAppBaseUrl } from '@cio/core/config/dashboard-url';
|
|
import { parseCourseIdsFromInviteMetadata, parseCohortIdsFromInviteMetadata } from '@api/utils/org';
|
|
import { getProfileById, markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
|
|
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
|
import { buildEmailBranding, buildEmailFromName, sanitizeEmailSubject } from '@cio/email';
|
|
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
|
|
|
|
type OrganizationInviteStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED' | 'ACCEPTED';
|
|
|
|
const ORG_INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
|
interface TInviteRequestContext {
|
|
ipAddress?: string | null;
|
|
userAgent?: string | null;
|
|
}
|
|
|
|
interface TAuthUser {
|
|
id: string;
|
|
email?: string | null;
|
|
}
|
|
|
|
function hashToken(token: string): string {
|
|
return crypto.createHash('sha256').update(token).digest('hex');
|
|
}
|
|
|
|
function generateToken(): string {
|
|
return crypto.randomBytes(32).toString('base64url');
|
|
}
|
|
|
|
function normalizeEmails(emails: string[]): string[] {
|
|
return [...new Set(emails.map((email) => email.toLowerCase().trim()).filter(Boolean))];
|
|
}
|
|
|
|
function buildTeamInviteLink(token: string): string {
|
|
return `${getAppBaseUrl()}/invite/${encodeURIComponent(token)}`;
|
|
}
|
|
|
|
function getRoleLabel(roleId: number): string {
|
|
if (roleId === ROLE.ADMIN) return 'Admin';
|
|
if (roleId === ROLE.TUTOR) return 'Tutor';
|
|
if (roleId === ROLE.STUDENT) return 'Student';
|
|
return `Role ${roleId}`;
|
|
}
|
|
|
|
function getInviteStatus(invite: {
|
|
isRevoked: boolean;
|
|
expiresAt: string;
|
|
acceptedAt: string | null;
|
|
}): OrganizationInviteStatus {
|
|
if (invite.isRevoked) {
|
|
return 'REVOKED';
|
|
}
|
|
|
|
if (invite.acceptedAt) {
|
|
return 'ACCEPTED';
|
|
}
|
|
|
|
if (new Date(invite.expiresAt).getTime() <= Date.now()) {
|
|
return 'EXPIRED';
|
|
}
|
|
|
|
return 'ACTIVE';
|
|
}
|
|
|
|
function getExpiryLabel(expiresAtIso: string): string {
|
|
return new Date(expiresAtIso).toLocaleString('en-US', {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
timeZone: 'UTC'
|
|
});
|
|
}
|
|
|
|
async function syncOrgMemberForOrgInvite(
|
|
tx: DbOrTxClient,
|
|
params: { organizationId: string; roleId: number; normalizedEmail: string; userId: string }
|
|
): Promise<void> {
|
|
const orgMemberByEmail = await selectOrganizationMemberByOrgAndNormalizedEmail(
|
|
tx,
|
|
params.organizationId,
|
|
params.normalizedEmail
|
|
);
|
|
|
|
if (orgMemberByEmail) {
|
|
if (orgMemberByEmail.profileId && orgMemberByEmail.profileId !== params.userId) {
|
|
throw new AppError('This invite is linked to another account', ErrorCodes.UNAUTHORIZED, 403);
|
|
}
|
|
|
|
await updateOrganizationMemberById(tx, orgMemberByEmail.id, {
|
|
profileId: params.userId,
|
|
roleId: params.roleId,
|
|
email: params.normalizedEmail,
|
|
verified: true
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
const orgMemberByProfile = await selectOrganizationMemberByOrgAndProfile(tx, params.organizationId, params.userId);
|
|
|
|
if (orgMemberByProfile) {
|
|
await updateOrganizationMemberById(tx, orgMemberByProfile.id, {
|
|
roleId: params.roleId,
|
|
email: params.normalizedEmail,
|
|
verified: true
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
if (params.roleId === ROLE.STUDENT) {
|
|
await assertStudentCapacityOrThrow(params.organizationId, 1);
|
|
}
|
|
|
|
await createOrganizationMember(
|
|
{
|
|
organizationId: params.organizationId,
|
|
roleId: params.roleId,
|
|
profileId: params.userId,
|
|
email: params.normalizedEmail,
|
|
verified: true
|
|
},
|
|
tx
|
|
);
|
|
}
|
|
|
|
async function recordOrganizationInviteAudit(
|
|
inviteId: string,
|
|
organizationId: string,
|
|
eventType: TNewOrganizationInviteAudit['eventType'],
|
|
context: {
|
|
actorProfileId?: string | null;
|
|
targetEmail?: string | null;
|
|
ipAddress?: string | null;
|
|
userAgent?: string | null;
|
|
metadata?: Record<string, unknown>;
|
|
} = {}
|
|
) {
|
|
await createOrganizationInviteAudit({
|
|
inviteId,
|
|
organizationId,
|
|
eventType,
|
|
actorProfileId: context.actorProfileId ?? null,
|
|
targetEmail: context.targetEmail ?? null,
|
|
ipAddress: context.ipAddress ?? null,
|
|
userAgent: context.userAgent ?? null,
|
|
metadata: context.metadata ?? {}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Creates secure organization role invites from org settings.
|
|
* Invites are role-aware and tokenized; legacy payload links are not used.
|
|
*/
|
|
export async function inviteTeamMembers(orgId: string, emails: string[], roleId: number, invitedByProfileId: string) {
|
|
if (roleId !== ROLE.ADMIN && roleId !== ROLE.TUTOR) {
|
|
throw new AppError('Invalid organization role for invite', ErrorCodes.VALIDATION_ERROR, 400, 'roleId');
|
|
}
|
|
|
|
const organization = await getOrganizationById(orgId);
|
|
if (!organization || !organization.siteName) {
|
|
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
|
|
}
|
|
|
|
const normalizedEmails = normalizeEmails(emails);
|
|
const existingEmails = await checkEmailsExistInOrg(orgId, normalizedEmails);
|
|
const emailsToInvite = normalizedEmails.filter((email) => !existingEmails.includes(email));
|
|
|
|
if (emailsToInvite.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const members = await createOrganizationMembers(
|
|
emailsToInvite.map((email) => ({
|
|
organizationId: orgId,
|
|
email,
|
|
roleId,
|
|
verified: false
|
|
}))
|
|
);
|
|
|
|
const revokedInvites = await revokeActiveOrganizationInvitesByEmails(orgId, emailsToInvite, invitedByProfileId);
|
|
await Promise.all(
|
|
revokedInvites.map((invite) =>
|
|
recordOrganizationInviteAudit(invite.id, orgId, 'REVOKED', {
|
|
actorProfileId: invitedByProfileId,
|
|
targetEmail: invite.email,
|
|
metadata: { reason: 'superseded_by_new_invite' }
|
|
})
|
|
)
|
|
);
|
|
|
|
const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString();
|
|
const roleName = getRoleLabel(roleId);
|
|
const inviterProfile = invitedByProfileId ? await getProfileById(invitedByProfileId) : null;
|
|
const inviterName = inviterProfile?.fullname?.trim() || undefined;
|
|
|
|
for (const email of emailsToInvite) {
|
|
try {
|
|
const token = generateToken();
|
|
const tokenHash = hashToken(token);
|
|
|
|
const invite = await createOrganizationInvite({
|
|
organizationId: orgId,
|
|
roleId,
|
|
email,
|
|
tokenHash,
|
|
createdByProfileId: invitedByProfileId,
|
|
expiresAt,
|
|
isRevoked: false,
|
|
metadata: {
|
|
source: 'ORG_SETTINGS_TEAM_INVITE'
|
|
}
|
|
});
|
|
|
|
await recordOrganizationInviteAudit(invite.id, orgId, 'CREATED', {
|
|
actorProfileId: invitedByProfileId,
|
|
targetEmail: email,
|
|
metadata: {
|
|
roleId,
|
|
roleName,
|
|
expiresAt
|
|
}
|
|
});
|
|
|
|
const inviteLink = buildTeamInviteLink(token);
|
|
|
|
try {
|
|
await enqueueTransactionalEmail('inviteTeacher', {
|
|
to: email,
|
|
fields: {
|
|
email,
|
|
orgName: organization.name,
|
|
orgSiteName: organization.siteName,
|
|
roleName,
|
|
inviterName,
|
|
expiresAt: getExpiryLabel(expiresAt),
|
|
inviteLink,
|
|
branding: buildEmailBranding(organization)
|
|
},
|
|
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
|
|
subject: sanitizeEmailSubject(`You have been invited to join ${organization.name} on ClassroomIO`),
|
|
idempotencyKey: `org-invite-teacher:${invite.id}`
|
|
});
|
|
|
|
// Record EMAIL_SENT optimistically — the worker retries up to 5 times,
|
|
// and a final failure flips the email_delivery row to `failed` for
|
|
// operator follow-up. EMAIL_FAILED here only means the enqueue itself
|
|
// failed (DB outbox write or transient validation error).
|
|
await recordOrganizationInviteAudit(invite.id, orgId, 'EMAIL_SENT', {
|
|
actorProfileId: invitedByProfileId,
|
|
targetEmail: email
|
|
});
|
|
} catch (emailError) {
|
|
const message = emailError instanceof Error ? emailError.message : 'Unknown email error';
|
|
|
|
await recordOrganizationInviteAudit(invite.id, orgId, 'EMAIL_FAILED', {
|
|
actorProfileId: invitedByProfileId,
|
|
targetEmail: email,
|
|
metadata: { error: message }
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to create org invite for ${email}:`, error);
|
|
}
|
|
}
|
|
|
|
return members;
|
|
}
|
|
|
|
/**
|
|
* Server-only invite preview by token (API-key protected at route layer).
|
|
*/
|
|
export async function previewOrganizationInvite(token: string, context: TInviteRequestContext = {}) {
|
|
const tokenHash = hashToken(token);
|
|
const data = await getOrganizationInviteByTokenHash(tokenHash);
|
|
|
|
if (!data) {
|
|
throw new AppError('Invalid invite link', ErrorCodes.NOT_FOUND, 404);
|
|
}
|
|
|
|
await recordOrganizationInviteAudit(data.invite.id, data.invite.organizationId, 'PREVIEWED', {
|
|
targetEmail: data.invite.email,
|
|
ipAddress: context.ipAddress,
|
|
userAgent: context.userAgent
|
|
});
|
|
|
|
return {
|
|
invite: {
|
|
id: data.invite.id,
|
|
roleId: data.invite.roleId,
|
|
roleLabel: getRoleLabel(data.invite.roleId),
|
|
email: data.invite.email,
|
|
expiresAt: data.invite.expiresAt,
|
|
status: getInviteStatus(data.invite)
|
|
},
|
|
organization: data.organization
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Authenticated acceptance for organization role invites.
|
|
*/
|
|
export async function acceptOrganizationInvite(token: string, user: TAuthUser, context: TInviteRequestContext = {}) {
|
|
if (!user.id || !user.email) {
|
|
throw new AppError('Authenticated user email is required', ErrorCodes.UNAUTHORIZED, 401);
|
|
}
|
|
|
|
const normalizedEmail = user.email.toLowerCase().trim();
|
|
const tokenHash = hashToken(token);
|
|
|
|
const result = await db.transaction(async (tx) => {
|
|
const row = await selectOrganizationInviteWithOrgByTokenHash(tx, tokenHash);
|
|
|
|
if (!row) {
|
|
throw new AppError('Invalid invite link', ErrorCodes.NOT_FOUND, 404);
|
|
}
|
|
|
|
const status = getInviteStatus(row.invite);
|
|
if (status === 'REVOKED') {
|
|
throw new AppError('This invite has been revoked', ErrorCodes.UNAUTHORIZED, 403);
|
|
}
|
|
if (status === 'EXPIRED') {
|
|
throw new AppError('This invite has expired', ErrorCodes.VALIDATION_ERROR, 400);
|
|
}
|
|
|
|
const inviteEmail = row.invite.email ?? '';
|
|
if (inviteEmail.toLowerCase().trim() !== normalizedEmail) {
|
|
throw new AppError('This invite is for a different email address', ErrorCodes.UNAUTHORIZED, 403);
|
|
}
|
|
|
|
if (status === 'ACCEPTED') {
|
|
await markUserAndProfileEmailVerified(user.id, tx);
|
|
|
|
return {
|
|
organization: row.organization,
|
|
roleId: row.invite.roleId,
|
|
alreadyAccepted: true
|
|
};
|
|
}
|
|
|
|
await syncOrgMemberForOrgInvite(tx, {
|
|
organizationId: row.invite.organizationId,
|
|
roleId: row.invite.roleId,
|
|
normalizedEmail,
|
|
userId: user.id
|
|
});
|
|
|
|
await markUserAndProfileEmailVerified(user.id, tx);
|
|
|
|
const acceptedInvite = await claimPendingOrganizationInvite(tx, row.invite.id, user.id);
|
|
|
|
if (!acceptedInvite) {
|
|
throw new AppError('Invite is no longer available', ErrorCodes.VALIDATION_ERROR, 409);
|
|
}
|
|
|
|
return {
|
|
organization: row.organization,
|
|
roleId: row.invite.roleId,
|
|
alreadyAccepted: false
|
|
};
|
|
});
|
|
|
|
const siteName = result.organization.siteName || '';
|
|
|
|
const invite = await getOrganizationInviteByTokenHash(tokenHash);
|
|
|
|
if (invite) {
|
|
await recordOrganizationInviteAudit(invite.invite.id, invite.invite.organizationId, 'ACCEPTED', {
|
|
actorProfileId: user.id,
|
|
targetEmail: normalizedEmail,
|
|
ipAddress: context.ipAddress,
|
|
userAgent: context.userAgent,
|
|
metadata: { alreadyAccepted: result.alreadyAccepted }
|
|
});
|
|
}
|
|
|
|
// Auto-enroll in courses if invite metadata contains courseIds (from audience import).
|
|
// Run regardless of alreadyAccepted — enrollment is idempotent and the user may
|
|
// have accepted the org invite previously without getting course access.
|
|
if (invite) {
|
|
const metadata = invite.invite.metadata;
|
|
const courseIds = parseCourseIdsFromInviteMetadata(metadata);
|
|
const cohortIds = parseCohortIdsFromInviteMetadata(metadata);
|
|
|
|
if (courseIds.length > 0) {
|
|
try {
|
|
// Same as importAudienceMembers after course ids are known: resolve groups from course rows only.
|
|
const courseGroupMappings = await getCourseGroupIds(courseIds);
|
|
const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[];
|
|
await enrollUsersInCourseGroups(
|
|
validGroupIds,
|
|
[{ profileId: user.id, email: normalizedEmail }],
|
|
invite.invite.roleId
|
|
);
|
|
await ensureComplianceEnrollmentRecordsForProfiles(courseIds, [user.id]);
|
|
} catch (error) {
|
|
console.error('acceptOrganizationInvite course enrollment error:', error);
|
|
}
|
|
}
|
|
|
|
if (cohortIds.length > 0) {
|
|
try {
|
|
const existingCohortMemberships = await getExistingCohortMembers(
|
|
cohortIds.map((cohortId) => ({ cohortId, profileId: user.id }))
|
|
);
|
|
const cohortIdsToInsert = cohortIds.filter(
|
|
(cohortId) => !existingCohortMemberships.has(`${cohortId}:${user.id}`)
|
|
);
|
|
|
|
await Promise.all(
|
|
cohortIdsToInsert.map((cohortId) =>
|
|
addCohortMember({
|
|
cohortId,
|
|
roleId: invite.invite.roleId,
|
|
profileId: user.id,
|
|
email: normalizedEmail
|
|
})
|
|
)
|
|
);
|
|
} catch (error) {
|
|
console.error('acceptOrganizationInvite cohort enrollment error:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org';
|
|
|
|
return {
|
|
organizationId: result.organization.id,
|
|
roleId: result.roleId,
|
|
alreadyAccepted: result.alreadyAccepted,
|
|
redirectTo
|
|
};
|
|
}
|
|
|
|
// ─── Link Invite ────────────────────────────────────────────────────────────
|
|
|
|
const LINK_INVITE_FAR_FUTURE_MS = 100 * 365 * 24 * 60 * 60 * 1000;
|
|
|
|
/**
|
|
* Returns the current org link invite if one already exists, otherwise creates one.
|
|
* One link invite per org — subsequent calls return the same record.
|
|
*/
|
|
export async function getOrCreateLinkInvite(orgId: string, roleId: number, profileId: string) {
|
|
if (roleId !== ROLE.ADMIN && roleId !== ROLE.TUTOR) {
|
|
throw new AppError('Invalid organization role for link invite', ErrorCodes.VALIDATION_ERROR, 400, 'roleId');
|
|
}
|
|
|
|
const existing = await getOrgLinkInvite(orgId);
|
|
|
|
if (existing) {
|
|
const token = (existing.metadata as Record<string, unknown>)?.token as string | undefined;
|
|
return { id: existing.id, token: token ?? '', roleId: existing.roleId, isRevoked: existing.isRevoked };
|
|
}
|
|
|
|
const token = generateToken();
|
|
const tokenHash = hashToken(token);
|
|
const expiresAt = new Date(Date.now() + LINK_INVITE_FAR_FUTURE_MS).toISOString();
|
|
|
|
const invite = await createLinkInvite({
|
|
organizationId: orgId,
|
|
roleId,
|
|
tokenHash,
|
|
createdByProfileId: profileId,
|
|
expiresAt,
|
|
metadata: { token, source: 'ORG_SETTINGS_LINK_INVITE' }
|
|
});
|
|
|
|
await recordOrganizationInviteAudit(invite.id, orgId, 'CREATED', {
|
|
actorProfileId: profileId,
|
|
metadata: { roleId, type: 'LINK' }
|
|
});
|
|
|
|
return { id: invite.id, token, roleId: invite.roleId, isRevoked: invite.isRevoked };
|
|
}
|
|
|
|
/**
|
|
* Returns current link invite for the org (for GET endpoint — returns null if none created yet).
|
|
*/
|
|
export async function fetchOrgLinkInvite(orgId: string) {
|
|
const row = await getOrgLinkInvite(orgId);
|
|
|
|
if (!row) return null;
|
|
|
|
const token = (row.metadata as Record<string, unknown>)?.token as string | undefined;
|
|
return { id: row.id, token: token ?? '', roleId: row.roleId, isRevoked: row.isRevoked };
|
|
}
|
|
|
|
/**
|
|
* Enables or disables the org link invite (toggle isRevoked).
|
|
*/
|
|
export async function toggleOrgLinkInvite(orgId: string, isRevoked: boolean, profileId: string) {
|
|
const updated = await setLinkInviteRevoked(orgId, isRevoked, profileId);
|
|
|
|
if (!updated) {
|
|
throw new AppError('Link invite not found', ErrorCodes.NOT_FOUND, 404);
|
|
}
|
|
|
|
const token = (updated.metadata as Record<string, unknown>)?.token as string | undefined;
|
|
return { id: updated.id, token: token ?? '', roleId: updated.roleId, isRevoked: updated.isRevoked };
|
|
}
|
|
|
|
/**
|
|
* Server-only preview for a link invite (API-key protected at route layer).
|
|
*/
|
|
export async function previewLinkInvite(token: string, context: TInviteRequestContext = {}) {
|
|
const tokenHash = hashToken(token);
|
|
const data = await getOrgLinkInviteWithOrg(db, tokenHash);
|
|
|
|
if (!data) {
|
|
throw new AppError('Invalid invite link', ErrorCodes.NOT_FOUND, 404);
|
|
}
|
|
|
|
await recordOrganizationInviteAudit(data.invite.id, data.invite.organizationId, 'PREVIEWED', {
|
|
ipAddress: context.ipAddress,
|
|
userAgent: context.userAgent
|
|
});
|
|
|
|
return {
|
|
invite: {
|
|
id: data.invite.id,
|
|
roleId: data.invite.roleId,
|
|
roleLabel: getRoleLabel(data.invite.roleId),
|
|
isRevoked: data.invite.isRevoked
|
|
},
|
|
organization: data.organization
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Accepts a link invite — adds the authenticated user to the org.
|
|
* The invite is NOT consumed (reusable). Anyone with the link can join.
|
|
*/
|
|
export async function acceptLinkInvite(token: string, user: TAuthUser, context: TInviteRequestContext = {}) {
|
|
if (!user.id || !user.email) {
|
|
throw new AppError('Authenticated user email is required', ErrorCodes.UNAUTHORIZED, 401);
|
|
}
|
|
|
|
const normalizedEmail = user.email.toLowerCase().trim();
|
|
const tokenHash = hashToken(token);
|
|
|
|
const result = await db.transaction(async (tx) => {
|
|
const row = await getOrgLinkInviteWithOrg(tx, tokenHash);
|
|
|
|
if (!row) {
|
|
throw new AppError('Invalid invite link', ErrorCodes.NOT_FOUND, 404);
|
|
}
|
|
|
|
if (row.invite.isRevoked) {
|
|
throw new AppError('This invite link has been disabled', ErrorCodes.UNAUTHORIZED, 403);
|
|
}
|
|
|
|
await syncOrgMemberForOrgInvite(tx, {
|
|
organizationId: row.invite.organizationId,
|
|
roleId: row.invite.roleId,
|
|
normalizedEmail,
|
|
userId: user.id
|
|
});
|
|
|
|
await markUserAndProfileEmailVerified(user.id, tx);
|
|
|
|
return { organization: row.organization, roleId: row.invite.roleId, inviteId: row.invite.id };
|
|
});
|
|
|
|
await recordOrganizationInviteAudit(result.inviteId, result.organization.id, 'ACCEPTED', {
|
|
actorProfileId: user.id,
|
|
targetEmail: normalizedEmail,
|
|
ipAddress: context.ipAddress,
|
|
userAgent: context.userAgent,
|
|
metadata: { source: 'LINK_INVITE' }
|
|
});
|
|
|
|
const siteName = result.organization.siteName || '';
|
|
const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org';
|
|
|
|
return { organizationId: result.organization.id, roleId: result.roleId, redirectTo };
|
|
}
|
|
|
|
// ─── End Link Invite ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Returns the pending org invite for the currently logged-in user, if one exists.
|
|
* Used by the LMS dashboard to prompt the student to accept on first load.
|
|
*/
|
|
export async function getPendingOrgInviteForUser(orgId: string, email: string) {
|
|
if (!orgId || !email) {
|
|
return null;
|
|
}
|
|
|
|
const data = await getActivePendingOrgInviteForEmail(orgId, email);
|
|
|
|
if (!data) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: data.invite.id,
|
|
email: data.invite.email,
|
|
roleId: data.invite.roleId,
|
|
roleLabel: getRoleLabel(data.invite.roleId),
|
|
expiresAt: data.invite.expiresAt,
|
|
organization: data.organization
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Accepts an organization invite by invite ID (for logged-in students with a pending invite).
|
|
* Replicates the acceptance logic of acceptOrganizationInvite but looks up by invite ID instead of token.
|
|
*/
|
|
export async function acceptOrganizationInviteById(
|
|
inviteId: string,
|
|
user: TAuthUser,
|
|
context: TInviteRequestContext = {}
|
|
) {
|
|
if (!user.id || !user.email) {
|
|
throw new AppError('Authenticated user email is required', ErrorCodes.UNAUTHORIZED, 401);
|
|
}
|
|
|
|
const normalizedEmail = user.email.toLowerCase().trim();
|
|
|
|
const result = await db.transaction(async (tx) => {
|
|
const row = await selectOrganizationInviteWithOrgByInviteId(tx, inviteId);
|
|
|
|
if (!row) {
|
|
throw new AppError('Invalid invite', ErrorCodes.NOT_FOUND, 404);
|
|
}
|
|
|
|
const status = getInviteStatus(row.invite);
|
|
if (status === 'REVOKED') {
|
|
throw new AppError('This invite has been revoked', ErrorCodes.UNAUTHORIZED, 403);
|
|
}
|
|
|
|
if (status === 'EXPIRED') {
|
|
throw new AppError('This invite has expired', ErrorCodes.VALIDATION_ERROR, 400);
|
|
}
|
|
|
|
const inviteEmailById = row.invite.email ?? '';
|
|
if (inviteEmailById.toLowerCase().trim() !== normalizedEmail) {
|
|
throw new AppError('This invite is for a different email address', ErrorCodes.UNAUTHORIZED, 403);
|
|
}
|
|
|
|
if (status === 'ACCEPTED') {
|
|
await markUserAndProfileEmailVerified(user.id, tx);
|
|
|
|
return { organization: row.organization, invite: row.invite, roleId: row.invite.roleId, alreadyAccepted: true };
|
|
}
|
|
|
|
await syncOrgMemberForOrgInvite(tx, {
|
|
organizationId: row.invite.organizationId,
|
|
roleId: row.invite.roleId,
|
|
normalizedEmail,
|
|
userId: user.id
|
|
});
|
|
|
|
await markUserAndProfileEmailVerified(user.id, tx);
|
|
|
|
const acceptedInvite = await claimPendingOrganizationInvite(tx, row.invite.id, user.id);
|
|
|
|
if (!acceptedInvite) {
|
|
throw new AppError('Invite is no longer available', ErrorCodes.VALIDATION_ERROR, 409);
|
|
}
|
|
|
|
return { organization: row.organization, invite: row.invite, roleId: row.invite.roleId, alreadyAccepted: false };
|
|
});
|
|
|
|
await recordOrganizationInviteAudit(result.invite.id, result.invite.organizationId, 'ACCEPTED', {
|
|
actorProfileId: user.id,
|
|
targetEmail: normalizedEmail,
|
|
ipAddress: context.ipAddress,
|
|
userAgent: context.userAgent,
|
|
metadata: { alreadyAccepted: result.alreadyAccepted }
|
|
});
|
|
|
|
const metadata = result.invite.metadata;
|
|
const courseIds = parseCourseIdsFromInviteMetadata(metadata);
|
|
const cohortIds = parseCohortIdsFromInviteMetadata(metadata);
|
|
|
|
if (courseIds.length > 0) {
|
|
try {
|
|
const courseGroupMappings = await getCourseGroupIds(courseIds);
|
|
const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[];
|
|
await enrollUsersInCourseGroups(
|
|
validGroupIds,
|
|
[{ profileId: user.id, email: normalizedEmail }],
|
|
result.invite.roleId
|
|
);
|
|
await ensureComplianceEnrollmentRecordsForProfiles(courseIds, [user.id]);
|
|
} catch (error) {
|
|
console.error('acceptOrganizationInviteById course enrollment error:', error);
|
|
}
|
|
}
|
|
|
|
if (cohortIds.length > 0) {
|
|
try {
|
|
const existingCohortMemberships = await getExistingCohortMembers(
|
|
cohortIds.map((cohortId) => ({ cohortId, profileId: user.id }))
|
|
);
|
|
const cohortIdsToInsert = cohortIds.filter(
|
|
(cohortId) => !existingCohortMemberships.has(`${cohortId}:${user.id}`)
|
|
);
|
|
|
|
await Promise.all(
|
|
cohortIdsToInsert.map((cohortId) =>
|
|
addCohortMember({ cohortId, roleId: result.invite.roleId, profileId: user.id, email: normalizedEmail })
|
|
)
|
|
);
|
|
} catch (error) {
|
|
console.error('acceptOrganizationInviteById cohort enrollment error:', error);
|
|
}
|
|
}
|
|
|
|
const siteName = result.organization.siteName || '';
|
|
const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org';
|
|
|
|
return {
|
|
organizationId: result.organization.id,
|
|
roleId: result.roleId,
|
|
alreadyAccepted: result.alreadyAccepted,
|
|
redirectTo
|
|
};
|
|
}
|