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:
@@ -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'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user