Enhance audience management features with new components and API integrations
- Added `AssignCoursesModal`, `AudienceMemberRow`, `AudienceTable`, and related components for improved audience management UI. - Implemented audience invite actions including resend and revoke functionality. - Updated audience API to support importing members and assigning courses. - Refactored audience data handling to include status and invite management. - Improved overall user experience with pagination and selection features in the audience table.
This commit is contained in:
@@ -11,7 +11,6 @@ import { getUserOrgRole } from '@cio/db/queries/organization';
|
||||
export const orgMemberMiddleware = async (c: Context, next: Next) => {
|
||||
try {
|
||||
const user = c.get('user');
|
||||
console.log('orgMemberMiddleware user', user);
|
||||
if (!user) {
|
||||
return c.json(
|
||||
{
|
||||
@@ -24,7 +23,6 @@ export const orgMemberMiddleware = async (c: Context, next: Next) => {
|
||||
}
|
||||
|
||||
const orgId = c.req.header('cio-org-id');
|
||||
console.log('orgMemberMiddleware orgId', orgId);
|
||||
if (!orgId) {
|
||||
return c.json(
|
||||
{
|
||||
@@ -37,7 +35,6 @@ export const orgMemberMiddleware = async (c: Context, next: Next) => {
|
||||
}
|
||||
|
||||
const roleId = await getUserOrgRole(orgId, user.id);
|
||||
console.log('orgMemberMiddleware roleId', orgId);
|
||||
if (roleId === null) {
|
||||
return c.json(
|
||||
{
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
ZAssignAudienceCourses,
|
||||
ZAudienceInviteByEmail,
|
||||
ZCancelOrgPlan,
|
||||
ZCreateOrgPlan,
|
||||
ZCreateOrganization,
|
||||
@@ -7,12 +9,20 @@ import {
|
||||
ZGetOrganizationCoursesQuery,
|
||||
ZGetOrganizations,
|
||||
ZGetUserAnalytics,
|
||||
ZImportAudienceMembers,
|
||||
ZInviteTeamMembers,
|
||||
ZLMSExercisesParam,
|
||||
ZRemoveTeamMember,
|
||||
ZUpdateOrgPlan,
|
||||
ZUpdateOrganization
|
||||
} from '@cio/utils/validation/organization';
|
||||
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
|
||||
import {
|
||||
assignAudienceToCourses,
|
||||
importAudienceMembers,
|
||||
resendAudienceInvite,
|
||||
revokeAudiencePendingInvite
|
||||
} from '@api/services/organization/audience';
|
||||
import {
|
||||
cancelOrgPlan,
|
||||
createOrg,
|
||||
@@ -33,13 +43,13 @@ import {
|
||||
} from '@api/services/organization';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { TOrganization } from '@db/types';
|
||||
import { automationRouter } from '@api/routes/organization/automation';
|
||||
import { assetsRouter } from '@api/routes/organization/assets';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
|
||||
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
|
||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||
import { automationRouter } from '@api/routes/organization/automation';
|
||||
import { courseImportRouter } from '@api/routes/organization/course-import';
|
||||
import { getLMSExercisesService } from '@api/services/exercise';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
@@ -47,9 +57,9 @@ import { inviteTeamMembers } from '@api/services/organization/invite';
|
||||
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||
import { orgMemberOrAutomationKeyMiddleware } from '@api/middlewares/org-member-or-automation-key';
|
||||
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
|
||||
import { quizRouter } from '@api/routes/organization/quiz';
|
||||
import { tagsRouter } from '@api/routes/organization/tags';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const organizationRouter = new Hono()
|
||||
@@ -100,7 +110,7 @@ export const organizationRouter = new Hono()
|
||||
* Gets organization team members (non-students)
|
||||
* Requires authentication
|
||||
*/
|
||||
.get('/team', authMiddleware, orgMemberMiddleware, async (c) => {
|
||||
.get('/team', authMiddleware, orgTeamMemberMiddleware, async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const team = await getOrgTeam(orgId);
|
||||
@@ -168,7 +178,7 @@ export const organizationRouter = new Hono()
|
||||
* Gets organization audience (students)
|
||||
* Requires authentication
|
||||
*/
|
||||
.get('/audience', authMiddleware, orgMemberMiddleware, async (c) => {
|
||||
.get('/audience', authMiddleware, orgTeamMemberMiddleware, async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const audience = await getOrgAudience(orgId);
|
||||
@@ -184,6 +194,48 @@ export const organizationRouter = new Hono()
|
||||
return handleError(c, error, 'Failed to fetch organization audience');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* POST /organization/audience/resend-invite
|
||||
* Resends a student org invite email (new token; optional course metadata from last invite)
|
||||
*/
|
||||
.post(
|
||||
'/audience/resend-invite',
|
||||
authMiddleware,
|
||||
orgTeamMemberMiddleware,
|
||||
zValidator('json', ZAudienceInviteByEmail),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const user = c.get('user')!;
|
||||
const data = c.req.valid('json');
|
||||
const result = await resendAudienceInvite(orgId, data, user.id);
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to resend audience invite');
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* POST /organization/audience/revoke-invite
|
||||
* Revokes an active (non-expired) pending student org invite
|
||||
*/
|
||||
.post(
|
||||
'/audience/revoke-invite',
|
||||
authMiddleware,
|
||||
orgTeamMemberMiddleware,
|
||||
zValidator('json', ZAudienceInviteByEmail),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const user = c.get('user')!;
|
||||
const data = c.req.valid('json');
|
||||
const result = await revokeAudiencePendingInvite(orgId, data, user.id);
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to revoke audience invite');
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* GET /organization/audience/:userId/analytics
|
||||
* Gets user analytics for a specific user in an organization
|
||||
@@ -192,7 +244,7 @@ export const organizationRouter = new Hono()
|
||||
.get(
|
||||
'/audience/:userId/analytics',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
orgTeamMemberMiddleware,
|
||||
zValidator('param', ZGetUserAnalytics),
|
||||
async (c) => {
|
||||
try {
|
||||
@@ -458,7 +510,6 @@ export const organizationRouter = new Hono()
|
||||
*/
|
||||
.put('/', authMiddleware, orgAdminMiddleware, zValidator('json', ZUpdateOrganization), async (c) => {
|
||||
try {
|
||||
console.log('updateOrg');
|
||||
console.log(c.req.valid('json'));
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
|
||||
@@ -515,6 +566,65 @@ export const organizationRouter = new Hono()
|
||||
return handleError(c, error, 'Failed to fetch LMS exercises');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* POST /organization/audience/import
|
||||
* Imports users into the organization as students with optional course assignment
|
||||
* Requires authentication and admin role
|
||||
*/
|
||||
.post(
|
||||
'/audience/import',
|
||||
authMiddleware,
|
||||
orgTeamMemberMiddleware,
|
||||
zValidator('json', ZImportAudienceMembers),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const user = c.get('user')!;
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const result = await importAudienceMembers(orgId, data, user.id);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
data: result
|
||||
},
|
||||
201
|
||||
);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to import audience members');
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* POST /organization/audience/assign-courses
|
||||
* Assigns existing audience members to one or more courses
|
||||
* Requires authentication and admin role
|
||||
*/
|
||||
.post(
|
||||
'/audience/assign-courses',
|
||||
authMiddleware,
|
||||
orgAdminMiddleware,
|
||||
zValidator('json', ZAssignAudienceCourses),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const result = await assignAudienceToCourses(orgId, data);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
data: result
|
||||
},
|
||||
200
|
||||
);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to assign audience to courses');
|
||||
}
|
||||
}
|
||||
)
|
||||
.route('/automation', automationRouter)
|
||||
.route('/course-import', courseImportRouter)
|
||||
.route('/tags', tagsRouter)
|
||||
|
||||
@@ -23,7 +23,7 @@ import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getCourseTeachers } from '@cio/db/queries/course/people';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { buildEmailFromName, sendEmail } from '@cio/email';
|
||||
import { getProfileByEmail } from '@cio/db/queries/auth';
|
||||
import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
|
||||
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
|
||||
@@ -948,6 +948,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
.limit(1);
|
||||
|
||||
if (existingMember) {
|
||||
await markUserAndProfileEmailVerified(user.id, tx);
|
||||
|
||||
await recordInviteAudit(invite.id, invite.courseId, 'ACCEPTED', {
|
||||
actorProfileId: user.id,
|
||||
targetEmail: normalizedEmail,
|
||||
@@ -1029,6 +1031,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
email: normalizedEmail
|
||||
});
|
||||
|
||||
await markUserAndProfileEmailVerified(user.id, tx);
|
||||
|
||||
const [consumeInvite] = await tx
|
||||
.update(schema.courseInvite)
|
||||
.set({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { getCourseStats, getDashOrgStats, getRecentEnrollments } from '@cio/db/queries/dash';
|
||||
|
||||
import { OrganisationAnalytics } from '@api/types';
|
||||
import { getOrgIdBySiteName } from '@db/queries';
|
||||
import { getOrgIdBySiteName } from '@cio/db/queries';
|
||||
|
||||
export async function getOrganisationAnalytics(orgId?: string, siteName?: string): Promise<OrganisationAnalytics> {
|
||||
const analytics: OrganisationAnalytics = {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type { TNewOrganizationPlan, TOrganization, TOrganizationPlan, TPlan } from '@db/types';
|
||||
import type { TNewOrganizationPlan, TOrganization, TOrganizationPlan } from '@db/types';
|
||||
import {
|
||||
cancelOrganizationPlan,
|
||||
createOrganizationPlan,
|
||||
deleteOrganizationMember,
|
||||
getOrgIdBySiteName,
|
||||
getFirstOrganizationWithPlans,
|
||||
getLatestOrgInvitesByEmails,
|
||||
getOrganizationAudience,
|
||||
getOrganizationBySiteName,
|
||||
getOrganizationTeam,
|
||||
@@ -32,6 +33,8 @@ import { ROLE } from '@cio/utils/constants';
|
||||
import { createOrganizationWithOwner } from '@api/services/onboarding';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { inviteTeamMembers as inviteTeamMembersSecure } from './organization/invite';
|
||||
import { deriveAudienceMemberStatus } from '@api/utils/audience-member-status';
|
||||
import type { OrgAudienceMember } from '@api/types/org';
|
||||
|
||||
/**
|
||||
* Creates a new organization with the current user as owner
|
||||
@@ -116,12 +119,26 @@ export async function getOrgTeam(orgId: string) {
|
||||
/**
|
||||
* Gets organization audience (students)
|
||||
* @param orgId - The organization ID
|
||||
* @returns Array of student profiles
|
||||
* @returns Audience members with invite status for profile-less rows
|
||||
*/
|
||||
export async function getOrgAudience(orgId: string) {
|
||||
export async function getOrgAudience(orgId: string): Promise<OrgAudienceMember[]> {
|
||||
try {
|
||||
const audience = await getOrganizationAudience(orgId);
|
||||
return audience;
|
||||
|
||||
const emailsWithoutProfile = audience.filter((m) => !m.profileId && m.email).map((m) => m.email.toLowerCase());
|
||||
|
||||
const invites = await getLatestOrgInvitesByEmails(orgId, emailsWithoutProfile);
|
||||
const inviteByEmail = new Map(invites.map((i) => [i.email.toLowerCase(), i]));
|
||||
|
||||
return audience.map(
|
||||
(member): OrgAudienceMember => ({
|
||||
...member,
|
||||
status: deriveAudienceMemberStatus(
|
||||
member.profileId,
|
||||
member.email ? inviteByEmail.get(member.email.toLowerCase()) : undefined
|
||||
)
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch organization audience',
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type {
|
||||
TAssignAudienceCourses,
|
||||
TAudienceInviteByEmail,
|
||||
TImportAudienceMembers
|
||||
} from '@cio/utils/validation/organization';
|
||||
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
|
||||
import { buildEmailFromName, sendEmail } from '@cio/email';
|
||||
import {
|
||||
checkEmailsExistInOrg,
|
||||
createOrganizationInvite,
|
||||
createOrganizationInviteAudits,
|
||||
createOrganizationInvites,
|
||||
createOrganizationMembers,
|
||||
getLatestOrganizationInviteRowByOrgAndEmail,
|
||||
getOrgMembersByProfileIds,
|
||||
getOrganizationById,
|
||||
getStudentOrganizationMemberByOrgAndEmail,
|
||||
hasActiveOrganizationInviteForEmail,
|
||||
revokeActiveOrganizationInvitesByEmails
|
||||
} from '@cio/db/queries/organization';
|
||||
import { getCourseGroupIds, getOrgCourseGroups, getOrgCourses } from '@cio/db/queries/course';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import crypto from 'node:crypto';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getProfilesByEmails } from '@cio/db/queries/auth';
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const ORG_INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
/** Parallel outbound invite emails; avoids sequential SMTP/API latency per recipient. */
|
||||
const EMAIL_SEND_CONCURRENCY = 5;
|
||||
|
||||
async function mapWithConcurrency<T, R>(items: T[], concurrency: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
if (items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const results: R[] = new Array(items.length);
|
||||
let next = 0;
|
||||
const workerCount = Math.min(concurrency, items.length);
|
||||
const workers = Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const i = next++;
|
||||
if (i >= items.length) break;
|
||||
results[i] = await fn(items[i]);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
function generateToken(): string {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
function buildInviteLink(token: string): string {
|
||||
return `${getDashboardBaseUrl()}/invite/${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
function getExpiryLabel(expiresAtIso: string): string {
|
||||
return new Date(expiresAtIso).toLocaleString('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
}
|
||||
|
||||
function parseRecipientCsv(recipientCsv: string): string[] {
|
||||
return recipientCsv
|
||||
.split(/[\n,;\t ]+/g)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getNormalizedRecipients(recipientCsv: string): {
|
||||
valid: string[];
|
||||
invalid: string[];
|
||||
duplicates: string[];
|
||||
} {
|
||||
const raw = parseRecipientCsv(recipientCsv);
|
||||
const seen = new Set<string>();
|
||||
const valid: string[] = [];
|
||||
const invalid: string[] = [];
|
||||
const duplicates: string[] = [];
|
||||
|
||||
for (const recipient of raw) {
|
||||
const normalized = recipient.toLowerCase().trim();
|
||||
if (!normalized) continue;
|
||||
|
||||
if (!EMAIL_REGEX.test(normalized)) {
|
||||
invalid.push(normalized);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.has(normalized)) {
|
||||
duplicates.push(normalized);
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(normalized);
|
||||
valid.push(normalized);
|
||||
}
|
||||
|
||||
return { valid, invalid, duplicates };
|
||||
}
|
||||
|
||||
export async function importAudienceMembers(orgId: string, data: TImportAudienceMembers, invitedByProfileId: string) {
|
||||
const organization = await getOrganizationById(orgId);
|
||||
if (!organization || !organization.siteName) {
|
||||
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const recipients = getNormalizedRecipients(data.recipientCsv);
|
||||
|
||||
if (recipients.invalid.length > 0) {
|
||||
throw new AppError(
|
||||
`Invalid emails found: ${recipients.invalid.slice(0, 5).join(', ')}`,
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400,
|
||||
'recipientCsv'
|
||||
);
|
||||
}
|
||||
|
||||
if (recipients.valid.length === 0) {
|
||||
throw new AppError('No valid emails provided', ErrorCodes.VALIDATION_ERROR, 400, 'recipientCsv');
|
||||
}
|
||||
|
||||
const existingEmails = await checkEmailsExistInOrg(orgId, recipients.valid);
|
||||
const emailsToImport = recipients.valid.filter((email) => !existingEmails.includes(email));
|
||||
|
||||
if (emailsToImport.length === 0) {
|
||||
return {
|
||||
imported: 0,
|
||||
skipped: existingEmails.length,
|
||||
duplicates: recipients.duplicates.length,
|
||||
emailsSent: 0,
|
||||
emailsFailed: 0
|
||||
};
|
||||
}
|
||||
|
||||
await createOrganizationMembers(
|
||||
emailsToImport.map((email) => ({
|
||||
organizationId: orgId,
|
||||
email,
|
||||
roleId: ROLE.STUDENT,
|
||||
verified: false
|
||||
}))
|
||||
);
|
||||
|
||||
let courseIds: string[] = [];
|
||||
let courseNames: string[] = [];
|
||||
|
||||
if (data.allCourses) {
|
||||
const courses = await getOrgCourses({ orgId });
|
||||
courseIds = courses.map((c) => c.id);
|
||||
courseNames = courses.map((c) => c.title).filter(Boolean);
|
||||
} else if (data.courseIds && data.courseIds.length > 0) {
|
||||
const courses = await getOrgCourses({ orgId, courseIds: data.courseIds });
|
||||
courseIds = courses.map((c) => c.id);
|
||||
courseNames = courses.map((c) => c.title).filter(Boolean);
|
||||
}
|
||||
if (courseIds.length > 0) {
|
||||
const courseGroupMappings = await getCourseGroupIds(courseIds);
|
||||
const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[];
|
||||
|
||||
if (validGroupIds.length > 0) {
|
||||
const profiles = await getProfilesByEmails(emailsToImport);
|
||||
if (profiles.length > 0) {
|
||||
const users = profiles.map((p) => ({ profileId: p.id, email: p.email ?? undefined }));
|
||||
await enrollUsersInCourseGroups(validGroupIds, users, ROLE.STUDENT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await revokeActiveOrganizationInvitesByEmails(orgId, emailsToImport, invitedByProfileId);
|
||||
|
||||
const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString();
|
||||
const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined;
|
||||
|
||||
const inviteInputs = emailsToImport.map((email) => {
|
||||
const token = generateToken();
|
||||
return {
|
||||
email,
|
||||
token,
|
||||
row: {
|
||||
organizationId: orgId,
|
||||
roleId: ROLE.STUDENT,
|
||||
email,
|
||||
tokenHash: hashToken(token),
|
||||
createdByProfileId: invitedByProfileId,
|
||||
expiresAt,
|
||||
isRevoked: false,
|
||||
metadata: {
|
||||
source: 'AUDIENCE_IMPORT',
|
||||
courseIds: courseIds.length > 0 ? courseIds : undefined
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const invites = await createOrganizationInvites(inviteInputs.map((i) => i.row));
|
||||
const inviteByEmail = new Map(invites.map((inv) => [inv.email.toLowerCase(), inv]));
|
||||
const tokenByEmail = new Map(inviteInputs.map((i) => [i.email.toLowerCase(), i.token]));
|
||||
|
||||
await createOrganizationInviteAudits(
|
||||
emailsToImport.map((email) => {
|
||||
const invite = inviteByEmail.get(email)!;
|
||||
return {
|
||||
inviteId: invite.id,
|
||||
organizationId: orgId,
|
||||
eventType: 'CREATED' as const,
|
||||
actorProfileId: invitedByProfileId,
|
||||
targetEmail: email,
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
metadata: {
|
||||
roleId: ROLE.STUDENT,
|
||||
roleName: 'Student',
|
||||
expiresAt,
|
||||
courseIds
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
let emailsSent = 0;
|
||||
let emailsFailed = 0;
|
||||
|
||||
if (data.sendEmail) {
|
||||
const emailOutcomes = await mapWithConcurrency(emailsToImport, EMAIL_SEND_CONCURRENCY, async (email) => {
|
||||
const invite = inviteByEmail.get(email)!;
|
||||
const token = tokenByEmail.get(email)!;
|
||||
try {
|
||||
const inviteLink = buildInviteLink(token);
|
||||
const responses = await sendEmail('studentOrgInvite', {
|
||||
to: email,
|
||||
fields: {
|
||||
email,
|
||||
orgName: organization.name,
|
||||
inviteLink,
|
||||
expiresAt: getExpiryLabel(expiresAt),
|
||||
courseNames: courseNamesLabel
|
||||
},
|
||||
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
|
||||
});
|
||||
|
||||
const allSuccessful = responses.every((r) => r.success);
|
||||
if (allSuccessful) {
|
||||
return {
|
||||
inviteId: invite.id,
|
||||
email,
|
||||
success: true as const,
|
||||
error: undefined as string | undefined
|
||||
};
|
||||
}
|
||||
return {
|
||||
inviteId: invite.id,
|
||||
email,
|
||||
success: false as const,
|
||||
error: responses
|
||||
.filter((r) => !r.success)
|
||||
.map((r) => r.error || 'Unknown')
|
||||
.join('; ')
|
||||
};
|
||||
} catch (emailError) {
|
||||
const message = emailError instanceof Error ? emailError.message : 'Unknown email error';
|
||||
return { inviteId: invite.id, email, success: false as const, error: message };
|
||||
}
|
||||
});
|
||||
|
||||
emailsSent = emailOutcomes.filter((o) => o.success).length;
|
||||
emailsFailed = emailOutcomes.length - emailsSent;
|
||||
|
||||
await createOrganizationInviteAudits(
|
||||
emailOutcomes.map((o) => ({
|
||||
inviteId: o.inviteId,
|
||||
organizationId: orgId,
|
||||
eventType: o.success ? ('EMAIL_SENT' as const) : ('EMAIL_FAILED' as const),
|
||||
actorProfileId: invitedByProfileId,
|
||||
targetEmail: o.email,
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
metadata: o.success ? {} : { error: o.error ?? 'Unknown' }
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
imported: emailsToImport.length,
|
||||
skipped: existingEmails.length,
|
||||
duplicates: recipients.duplicates.length,
|
||||
emailsSent,
|
||||
emailsFailed
|
||||
};
|
||||
}
|
||||
|
||||
export async function resendAudienceInvite(orgId: string, data: TAudienceInviteByEmail, invitedByProfileId: string) {
|
||||
const organization = await getOrganizationById(orgId);
|
||||
if (!organization || !organization.siteName) {
|
||||
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const member = await getStudentOrganizationMemberByOrgAndEmail(orgId, data.email);
|
||||
if (!member) {
|
||||
throw new AppError('Audience member not found', ErrorCodes.VALIDATION_ERROR, 404, 'email');
|
||||
}
|
||||
if (member.profileId) {
|
||||
throw new AppError('Member has already joined', ErrorCodes.VALIDATION_ERROR, 400, 'email');
|
||||
}
|
||||
|
||||
const emailToUse = member.email.toLowerCase().trim();
|
||||
|
||||
const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, emailToUse);
|
||||
const meta = latestInvite?.metadata as { courseIds?: string[] } | undefined;
|
||||
const courseIdsFromMetadata = meta?.courseIds?.filter(Boolean) ?? [];
|
||||
|
||||
let courseIds: string[] = [];
|
||||
let courseNames: string[] = [];
|
||||
if (courseIdsFromMetadata.length > 0) {
|
||||
const courses = await getOrgCourses({ orgId, courseIds: courseIdsFromMetadata });
|
||||
courseIds = courses.map((c) => c.id);
|
||||
courseNames = courses.map((c) => c.title).filter(Boolean);
|
||||
}
|
||||
|
||||
await revokeActiveOrganizationInvitesByEmails(orgId, [emailToUse], invitedByProfileId);
|
||||
|
||||
const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString();
|
||||
const token = generateToken();
|
||||
const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined;
|
||||
|
||||
const invite = await createOrganizationInvite({
|
||||
organizationId: orgId,
|
||||
roleId: ROLE.STUDENT,
|
||||
email: emailToUse,
|
||||
tokenHash: hashToken(token),
|
||||
createdByProfileId: invitedByProfileId,
|
||||
expiresAt,
|
||||
isRevoked: false,
|
||||
metadata: {
|
||||
source: 'AUDIENCE_RESEND',
|
||||
courseIds: courseIds.length > 0 ? courseIds : undefined
|
||||
}
|
||||
});
|
||||
|
||||
await createOrganizationInviteAudits([
|
||||
{
|
||||
inviteId: invite.id,
|
||||
organizationId: orgId,
|
||||
eventType: 'CREATED',
|
||||
actorProfileId: invitedByProfileId,
|
||||
targetEmail: emailToUse,
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
metadata: {
|
||||
roleId: ROLE.STUDENT,
|
||||
roleName: 'Student',
|
||||
expiresAt,
|
||||
courseIds
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
let emailSent = false;
|
||||
try {
|
||||
const inviteLink = buildInviteLink(token);
|
||||
const responses = await sendEmail('studentOrgInvite', {
|
||||
to: emailToUse,
|
||||
fields: {
|
||||
email: emailToUse,
|
||||
orgName: organization.name,
|
||||
inviteLink,
|
||||
expiresAt: getExpiryLabel(expiresAt),
|
||||
courseNames: courseNamesLabel
|
||||
},
|
||||
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
|
||||
});
|
||||
const allSuccessful = responses.every((r) => r.success);
|
||||
emailSent = allSuccessful;
|
||||
await createOrganizationInviteAudits([
|
||||
{
|
||||
inviteId: invite.id,
|
||||
organizationId: orgId,
|
||||
eventType: allSuccessful ? 'EMAIL_SENT' : 'EMAIL_FAILED',
|
||||
actorProfileId: invitedByProfileId,
|
||||
targetEmail: emailToUse,
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
metadata: allSuccessful
|
||||
? {}
|
||||
: {
|
||||
error: responses
|
||||
.filter((r) => !r.success)
|
||||
.map((r) => r.error || 'Unknown')
|
||||
.join('; ')
|
||||
}
|
||||
}
|
||||
]);
|
||||
} catch (emailError) {
|
||||
const message = emailError instanceof Error ? emailError.message : 'Unknown email error';
|
||||
await createOrganizationInviteAudits([
|
||||
{
|
||||
inviteId: invite.id,
|
||||
organizationId: orgId,
|
||||
eventType: 'EMAIL_FAILED',
|
||||
actorProfileId: invitedByProfileId,
|
||||
targetEmail: emailToUse,
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
metadata: { error: message }
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
return { emailSent };
|
||||
}
|
||||
|
||||
export async function revokeAudiencePendingInvite(
|
||||
orgId: string,
|
||||
data: TAudienceInviteByEmail,
|
||||
revokedByProfileId: string
|
||||
) {
|
||||
const member = await getStudentOrganizationMemberByOrgAndEmail(orgId, data.email);
|
||||
if (!member) {
|
||||
throw new AppError('Audience member not found', ErrorCodes.VALIDATION_ERROR, 404, 'email');
|
||||
}
|
||||
if (member.profileId) {
|
||||
throw new AppError('Member has already joined', ErrorCodes.VALIDATION_ERROR, 400, 'email');
|
||||
}
|
||||
|
||||
const emailToUse = member.email.toLowerCase().trim();
|
||||
|
||||
const hasActive = await hasActiveOrganizationInviteForEmail(orgId, emailToUse);
|
||||
if (!hasActive) {
|
||||
throw new AppError('No active pending invite to revoke', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const revoked = await revokeActiveOrganizationInvitesByEmails(orgId, [emailToUse], revokedByProfileId);
|
||||
if (revoked.length === 0) {
|
||||
throw new AppError('No invite was revoked', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
await createOrganizationInviteAudits(
|
||||
revoked.map((inv) => ({
|
||||
inviteId: inv.id,
|
||||
organizationId: orgId,
|
||||
eventType: 'REVOKED' as const,
|
||||
actorProfileId: revokedByProfileId,
|
||||
targetEmail: emailToUse,
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
metadata: { reason: 'audience_revoked_by_admin' }
|
||||
}))
|
||||
);
|
||||
|
||||
return { revoked: true };
|
||||
}
|
||||
|
||||
export async function assignAudienceToCourses(orgId: string, data: TAssignAudienceCourses) {
|
||||
const organization = await getOrganizationById(orgId);
|
||||
if (!organization) {
|
||||
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const orgMembers = await getOrgMembersByProfileIds(orgId, data.profileIds);
|
||||
|
||||
const validProfileIds = new Set(orgMembers.map((m) => m.profileId).filter(Boolean));
|
||||
const profileEmailMap = new Map(orgMembers.filter((m) => m.profileId).map((m) => [m.profileId!, m.email ?? '']));
|
||||
|
||||
const courseGroups = await getOrgCourseGroups(orgId, data.courseIds);
|
||||
|
||||
if (courseGroups.length === 0) {
|
||||
throw new AppError('No valid courses found', ErrorCodes.VALIDATION_ERROR, 400, 'courseIds');
|
||||
}
|
||||
|
||||
const validGroupIds = courseGroups.map((cg) => cg.groupId).filter(Boolean) as string[];
|
||||
const courseTitleByGroupId = new Map(courseGroups.map((cg) => [cg.groupId, cg.courseTitle]));
|
||||
const validProfiles = data.profileIds.filter((id) => validProfileIds.has(id));
|
||||
|
||||
const pairs = validProfiles.flatMap((profileId) => validGroupIds.map((groupId) => ({ groupId, profileId })));
|
||||
|
||||
const existingSet = await getExistingGroupMembers(pairs);
|
||||
|
||||
const toInsert = pairs.filter((p) => !existingSet.has(`${p.groupId}:${p.profileId}`));
|
||||
const alreadyEnrolled = pairs.length - toInsert.length;
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
await addGroupMembers(
|
||||
toInsert.map((p) => ({
|
||||
groupId: p.groupId,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: p.profileId,
|
||||
email: profileEmailMap.get(p.profileId) || undefined
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
let emailsSent = 0;
|
||||
|
||||
if (data.sendEmail && toInsert.length > 0) {
|
||||
const emailPromises = toInsert
|
||||
.filter((p) => profileEmailMap.get(p.profileId))
|
||||
.map(async (p) => {
|
||||
const email = profileEmailMap.get(p.profileId)!;
|
||||
try {
|
||||
await sendEmail('studentCourseWelcome', {
|
||||
to: email,
|
||||
fields: {
|
||||
orgName: organization.name,
|
||||
courseName: courseTitleByGroupId.get(p.groupId) || 'Course'
|
||||
},
|
||||
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
|
||||
});
|
||||
emailsSent++;
|
||||
} catch (emailError) {
|
||||
console.error(`assignAudienceToCourses email error for ${email}:`, emailError);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(emailPromises);
|
||||
}
|
||||
|
||||
return {
|
||||
assigned: toInsert.length,
|
||||
alreadyEnrolled,
|
||||
emailsSent
|
||||
};
|
||||
}
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
getOrganizationInviteByTokenHash,
|
||||
revokeActiveOrganizationInvitesByEmails
|
||||
} from '@cio/db/queries/organization';
|
||||
import { getCourseGroupIds } from '@cio/db/queries/course';
|
||||
import { enrollUsersInCourseGroups } from '@cio/db/queries/group';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import crypto from 'node:crypto';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { parseCourseIdsFromInviteMetadata } from '@api/utils/org';
|
||||
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
|
||||
import { sendEmail } from '@cio/email';
|
||||
|
||||
@@ -46,12 +49,13 @@ function normalizeEmails(emails: string[]): string[] {
|
||||
}
|
||||
|
||||
function buildInviteLink(token: string): string {
|
||||
return `${getDashboardBaseUrl()}/invite/t/${encodeURIComponent(token)}`;
|
||||
return `${getDashboardBaseUrl()}/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}`;
|
||||
}
|
||||
|
||||
@@ -405,6 +409,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
||||
const siteName = result.organization.siteName || '';
|
||||
|
||||
const invite = await getOrganizationInviteByTokenHash(tokenHash);
|
||||
|
||||
if (invite) {
|
||||
await recordOrganizationInviteAudit(invite.invite.id, invite.invite.organizationId, 'ACCEPTED', {
|
||||
actorProfileId: user.id,
|
||||
@@ -415,10 +420,35 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
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
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('acceptOrganizationInvite course 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: siteName ? `/org/${siteName}` : '/org'
|
||||
redirectTo
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Re-export all types
|
||||
export * from './database';
|
||||
export * from './dash';
|
||||
export * from './org';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AudienceMemberStatus } from '@api/utils/audience-member-status';
|
||||
|
||||
export type OrgAudienceMember = {
|
||||
id: number;
|
||||
profileId: string | null;
|
||||
name: string;
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
createdAt: string;
|
||||
status: AudienceMemberStatus;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export type AudienceMemberStatus = 'active' | 'pending' | 'expired' | 'revoked';
|
||||
|
||||
export function deriveAudienceMemberStatus(
|
||||
profileId: string | null,
|
||||
invite: { acceptedAt: string | null; isRevoked: boolean; expiresAt: string } | undefined
|
||||
): AudienceMemberStatus {
|
||||
if (profileId) {
|
||||
return 'active';
|
||||
}
|
||||
if (invite?.acceptedAt) {
|
||||
return 'active';
|
||||
}
|
||||
if (!invite) {
|
||||
return 'pending';
|
||||
}
|
||||
if (invite.isRevoked) {
|
||||
return 'revoked';
|
||||
}
|
||||
if (new Date(invite.expiresAt) <= new Date()) {
|
||||
return 'expired';
|
||||
}
|
||||
return 'pending';
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Normalize JSONB organization-invite metadata for course ids (matches importAudienceMembers storage). */
|
||||
export function parseCourseIdsFromInviteMetadata(metadata: unknown): string[] {
|
||||
let obj: Record<string, unknown> | null = null;
|
||||
if (metadata == null) {
|
||||
return [];
|
||||
}
|
||||
if (typeof metadata === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(metadata) as unknown;
|
||||
obj = typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
} else if (typeof metadata === 'object') {
|
||||
obj = metadata as Record<string, unknown>;
|
||||
}
|
||||
if (!obj) {
|
||||
return [];
|
||||
}
|
||||
const raw = obj.courseIds ?? obj.course_ids;
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
return raw.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
}
|
||||
Reference in New Issue
Block a user