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:
Rotimi Best
2026-03-22 18:42:23 +01:00
parent e9c4082f89
commit 3919ff6c2f
157 changed files with 13183 additions and 872 deletions
+7 -1
View File
@@ -2,7 +2,7 @@ import * as schema from '@db/schema';
import type { TProfile } from '@db/types';
import { db, type DbOrTxClient } from '@db/drizzle';
import { eq } from 'drizzle-orm';
import { eq, inArray } from 'drizzle-orm';
export const getProfileById = async (id: string) => {
const [profile] = await db.select().from(schema.profile).where(eq(schema.profile.id, id)).limit(1);
@@ -16,6 +16,12 @@ export const getProfileByEmail = async (email: string) => {
return profile;
};
export async function getProfilesByEmails(emails: string[]) {
if (emails.length === 0) return [];
return db.select().from(schema.profile).where(inArray(schema.profile.email, emails));
}
export const updateProfile = async (id: string, data: Partial<Omit<TProfile, 'id' | 'createdAt' | 'updatedAt'>>) => {
const [updatedProfile] = await db.update(schema.profile).set(data).where(eq(schema.profile.id, id)).returning();
+44
View File
@@ -879,3 +879,47 @@ export async function deleteCourseSection(sectionId: string, dbClient: DbOrTxCli
);
}
}
/**
* Gets group IDs for multiple courses at once
* @param courseIds Array of course IDs
* @returns Array of { courseId, groupId } mappings
*/
export async function getCourseGroupIds(courseIds: string[]) {
try {
if (courseIds.length === 0) return [];
return db
.select({ courseId: schema.course.id, groupId: schema.course.groupId })
.from(schema.course)
.where(inArray(schema.course.id, courseIds));
} catch (error) {
console.error('getCourseGroupIds error:', error);
throw new Error(`Failed to get course group IDs: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Gets course group mappings for an organization, including course title
* @param orgId Organization ID
* @param courseIds Array of course IDs to filter
* @returns Array of { courseId, courseTitle, groupId }
*/
export async function getOrgCourseGroups(orgId: string, courseIds: string[]) {
try {
if (courseIds.length === 0) return [];
return db
.select({
courseId: schema.course.id,
courseTitle: schema.course.title,
groupId: schema.course.groupId
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.where(and(eq(schema.group.organizationId, orgId), inArray(schema.course.id, courseIds)));
} catch (error) {
console.error('getOrgCourseGroups error:', error);
throw new Error(`Failed to get org course groups: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
+86 -1
View File
@@ -1,7 +1,7 @@
import * as schema from '@db/schema';
import { TNewGroup, TNewGroupmember } from '@db/types';
import { and, eq, isNotNull, or } from 'drizzle-orm';
import { and, eq, inArray, isNotNull, or } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
@@ -24,6 +24,91 @@ export async function addGroupMember(values: TNewGroupmember) {
}
}
export async function addGroupMembers(values: TNewGroupmember[]) {
if (values.length === 0) return [];
try {
return db.insert(schema.groupmember).values(values).returning();
} catch (error) {
console.error('addGroupMembers error:', error);
throw new Error(`Failed to bulk add group members: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getExistingGroupMembers(
pairs: Array<{ groupId: string; profileId: string }>
): Promise<Set<string>> {
if (pairs.length === 0) return new Set();
try {
const groupIds = [...new Set(pairs.map((p) => p.groupId))];
const profileIds = [...new Set(pairs.map((p) => p.profileId))];
const rows = await db
.select({ groupId: schema.groupmember.groupId, profileId: schema.groupmember.profileId })
.from(schema.groupmember)
.where(and(inArray(schema.groupmember.groupId, groupIds), inArray(schema.groupmember.profileId, profileIds)));
return new Set(rows.map((r) => `${r.groupId}:${r.profileId}`));
} catch (error) {
console.error('getExistingGroupMembers error:', error);
throw new Error(
`Failed to check existing group members: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Enroll users in course groups, skipping any existing memberships.
* Returns the count of newly inserted memberships.
*/
export async function enrollUsersInCourseGroups(
groupIds: string[],
users: Array<{ profileId: string; email?: string }>,
roleId: number
): Promise<number> {
if (groupIds.length === 0 || users.length === 0) return 0;
const pairs = users.flatMap((u) => groupIds.map((groupId) => ({ groupId, profileId: u.profileId })));
const existingSet = await getExistingGroupMembers(pairs);
const emailByProfile = new Map(users.map((u) => [u.profileId, u.email]));
const toInsert = pairs
.filter((p) => !existingSet.has(`${p.groupId}:${p.profileId}`))
.map((p) => ({
groupId: p.groupId,
roleId,
profileId: p.profileId,
email: emailByProfile.get(p.profileId) ?? undefined
}));
if (toInsert.length > 0) {
await addGroupMembers(toInsert);
}
return toInsert.length;
}
/**
* Checks if a profile is already a member of a specific group
* @param groupId Group ID
* @param profileId Profile ID
* @returns true if the user is already a member
*/
export async function isGroupMember(groupId: string, profileId: string): Promise<boolean> {
try {
const [existing] = await db
.select({ id: schema.groupmember.id })
.from(schema.groupmember)
.where(and(eq(schema.groupmember.groupId, groupId), eq(schema.groupmember.profileId, profileId)))
.limit(1);
return !!existing;
} catch (error) {
console.error('isGroupMember error:', error);
throw new Error(`Failed to check group membership: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Checks if a user is a member of a course's group (any role)
* @param courseId Course ID
+126 -1
View File
@@ -6,7 +6,7 @@ import type {
} from '@db/types';
import * as schema from '@db/schema';
import { and, eq, gt, inArray, isNull, sql } from 'drizzle-orm';
import { and, desc, eq, gt, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
export async function createOrganizationInvite(values: TNewOrganizationInvite): Promise<TOrganizationInvite> {
@@ -26,6 +26,21 @@ export async function createOrganizationInvite(values: TNewOrganizationInvite):
}
}
export async function createOrganizationInvites(values: TNewOrganizationInvite[]): Promise<TOrganizationInvite[]> {
if (values.length === 0) {
return [];
}
try {
return await db.insert(schema.organizationInvite).values(values).returning();
} catch (error) {
console.error('createOrganizationInvites error:', error);
throw new Error(
`Failed to create organization invites: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createOrganizationInviteAudit(
values: TNewOrganizationInviteAudit
): Promise<TOrganizationInviteAudit> {
@@ -45,6 +60,23 @@ export async function createOrganizationInviteAudit(
}
}
export async function createOrganizationInviteAudits(
values: TNewOrganizationInviteAudit[]
): Promise<TOrganizationInviteAudit[]> {
if (values.length === 0) {
return [];
}
try {
return await db.insert(schema.organizationInviteAudit).values(values).returning();
} catch (error) {
console.error('createOrganizationInviteAudits error:', error);
throw new Error(
`Failed to create organization invite audit events: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function revokeActiveOrganizationInvitesByEmails(
organizationId: string,
emails: string[],
@@ -180,3 +212,96 @@ export async function hasActiveOrganizationInviteForEmail(organizationId: string
return false;
}
}
export type TLatestOrgInviteByEmail = {
email: string;
acceptedAt: string | null;
isRevoked: boolean;
expiresAt: string;
createdAt: string;
};
/**
* Returns the latest invite per email (by created_at) for the given org and email list.
*/
export async function getLatestOrgInvitesByEmails(
organizationId: string,
emails: string[]
): Promise<TLatestOrgInviteByEmail[]> {
if (emails.length === 0) {
return [];
}
const normalized = [...new Set(emails.map((e) => e.toLowerCase().trim()))].filter(Boolean);
if (normalized.length === 0) {
return [];
}
try {
const rows = await db
.select({
email: schema.organizationInvite.email,
acceptedAt: schema.organizationInvite.acceptedAt,
isRevoked: schema.organizationInvite.isRevoked,
expiresAt: schema.organizationInvite.expiresAt,
createdAt: schema.organizationInvite.createdAt
})
.from(schema.organizationInvite)
.where(
and(
eq(schema.organizationInvite.organizationId, organizationId),
inArray(schema.organizationInvite.email, normalized)
)
);
const latestByEmail = new Map<string, TLatestOrgInviteByEmail>();
for (const row of rows) {
const key = row.email.toLowerCase();
const existing = latestByEmail.get(key);
if (!existing || new Date(row.createdAt) > new Date(existing.createdAt)) {
latestByEmail.set(key, row);
}
}
return Array.from(latestByEmail.values());
} catch (error) {
console.error('getLatestOrgInvitesByEmails error:', error);
throw new Error(
`Failed to get latest organization invites by emails: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Latest organization_invite row for an org + email (by created_at), for metadata when resending.
*/
export async function getLatestOrganizationInviteRowByOrgAndEmail(
organizationId: string,
email: string
): Promise<TOrganizationInvite | null> {
const normalized = email.toLowerCase().trim();
if (!normalized) {
return null;
}
try {
const [row] = await db
.select()
.from(schema.organizationInvite)
.where(
and(
eq(schema.organizationInvite.organizationId, organizationId),
eq(schema.organizationInvite.email, normalized)
)
)
.orderBy(desc(schema.organizationInvite.createdAt))
.limit(1);
return row ?? null;
} catch (error) {
console.error('getLatestOrganizationInviteRowByOrgAndEmail error:', error);
throw new Error(
`Failed to get latest organization invite: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
@@ -100,7 +100,7 @@ export const createOrganizationMember = async (data: TNewOrganizationmember, dbC
* @returns Array of created members
*/
export const createOrganizationMembers = async (data: TNewOrganizationmember[]) => {
const members = await db.insert(schema.organizationmember).values(data).returning();
const members = await db.insert(schema.organizationmember).values(data).onConflictDoNothing().returning();
return members;
};
@@ -345,34 +345,93 @@ export const getOrganizationTeam = async (orgId: string) => {
};
/**
* Gets organization audience (all organization members with student role)
* @param orgId Organization ID
* @returns Array of student profiles
* Gets organization audience (all organization members with student role).
* Includes invited members without a profile (LEFT JOIN profile).
* Row id is organizationmember.id; use profileId for profile-backed actions when present.
*/
export const getOrganizationAudience = async (orgId: string) => {
const result = await db
.select({
id: schema.profile.id,
memberId: schema.organizationmember.id,
profileId: schema.profile.id,
fullname: schema.profile.fullname,
email: schema.profile.email,
email: sql<string>`COALESCE(${schema.profile.email}, ${schema.organizationmember.email})`.as('email'),
avatarUrl: schema.profile.avatarUrl,
createdAt: schema.profile.createdAt
profileCreatedAt: schema.profile.createdAt,
memberCreatedAt: schema.organizationmember.createdAt
})
.from(schema.organizationmember)
.innerJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
.where(
and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.roleId, ROLE.STUDENT))
);
return result.map((profile) => ({
id: profile.id,
name: profile.fullname,
email: profile.email || '',
avatarUrl: profile.avatarUrl || '',
createdAt: profile.createdAt ? new Date(profile.createdAt).toDateString() : ''
}));
return result.map((row) => {
const email = row.email?.trim() ?? '';
const name = row.fullname?.trim() || (email.includes('@') ? email.split('@')[0] : email) || '';
const createdAtRaw = row.profileId ? row.profileCreatedAt : row.memberCreatedAt;
const createdAt = createdAtRaw ? new Date(createdAtRaw).toDateString() : '';
return {
id: row.memberId,
profileId: row.profileId ?? null,
name,
email,
avatarUrl: row.avatarUrl || '',
createdAt
};
});
};
/**
* Student org member matched by profile email or organizationmember.email (for audience invite actions).
*/
export async function getStudentOrganizationMemberByOrgAndEmail(
orgId: string,
email: string
): Promise<{ id: number; profileId: string | null; email: string } | null> {
const normalized = email.toLowerCase().trim();
if (!normalized) {
return null;
}
try {
const [row] = await db
.select({
id: schema.organizationmember.id,
profileId: schema.organizationmember.profileId,
displayEmail: sql<string>`COALESCE(${schema.profile.email}, ${schema.organizationmember.email})`.as(
'displayEmail'
)
})
.from(schema.organizationmember)
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
.where(
and(
eq(schema.organizationmember.organizationId, orgId),
eq(schema.organizationmember.roleId, ROLE.STUDENT),
or(eq(schema.organizationmember.email, normalized), eq(schema.profile.email, normalized))
)
)
.limit(1);
if (!row) {
return null;
}
return {
id: row.id,
profileId: row.profileId,
email: (row.displayEmail ?? normalized).trim()
};
} catch (error) {
console.error('getStudentOrganizationMemberByOrgAndEmail error:', error);
throw new Error(
`Failed to get organization student member: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Gets organizations with optional filters
* @param filters Filter options (siteName, customDomain, isCustomDomainVerified)
@@ -687,3 +746,33 @@ export const getActiveOrganizationPlan = async (orgId: string) => {
return plan ?? null;
};
/**
* Gets organization members by profile IDs
* @param orgId Organization ID
* @param profileIds Array of profile IDs to look up
* @returns Array of { profileId, email } for matching members
*/
export async function getOrgMembersByProfileIds(orgId: string, profileIds: string[]) {
try {
if (profileIds.length === 0) return [];
return db
.select({
profileId: schema.organizationmember.profileId,
email: schema.organizationmember.email
})
.from(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, orgId),
inArray(schema.organizationmember.profileId, profileIds)
)
);
} catch (error) {
console.error('getOrgMembersByProfileIds error:', error);
throw new Error(
`Failed to get org members by profile IDs: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}