* feat: move community supabase request to api layer * chore: pr review checkpoint * chore: PR fixes * feat: implement role-based authorization middleware and align the community features with the developer guide. - Added role-based authorization middleware for organization and course membership checks, including `orgMemberMiddleware`, `orgTeamMemberMiddleware`, and `courseMemberMiddleware`. - Introduced route-specific middleware for comment and question authorization, ensuring only authors or team members can modify content. - Updated community routes to utilize the new middleware for enhanced security and access control. - Refactored community services to streamline upvote functionality and improve error handling. - Enhanced UI components for community interactions, including new comment and question pages, and improved state management for user actions. - Updated documentation to reflect new middleware usage and best practices for authorization in the API layer. * feat: enhance course and organization routes with middleware for improved access control - Added `courseMemberMiddleware` to course download and clone routes to enforce membership checks. - Updated lesson route to include `courseMemberMiddleware` for PDF downloads. - Integrated `orgMemberMiddleware` into organization routes for team and audience access, ensuring only authorized members can retrieve data. - Refactored lesson and organization routes for consistency and clarity in middleware usage. * chore: streamline dashboard and API development scripts in package.json - Consolidated dashboard and API development commands to include all relevant filters in a single line for improved clarity and ease of use. - Removed redundant full command variants to simplify the script section. --------- Co-authored-by: Rotimi Best <best@classroomio.com>
382 lines
11 KiB
TypeScript
382 lines
11 KiB
TypeScript
import { AppError, ErrorCodes } from '@api/utils/errors';
|
|
import type { TNewOrganizationPlan, TOrganization, TOrganizationPlan, TPlan } from '@db/types';
|
|
import {
|
|
cancelOrganizationPlan,
|
|
checkEmailsExistInOrg,
|
|
createOrganizationMembers,
|
|
createOrganizationPlan,
|
|
deleteOrganizationMember,
|
|
getOrganizationAudience,
|
|
getOrganizationById,
|
|
getOrganizationBySiteName,
|
|
getOrganizationTeam,
|
|
getOrganizations,
|
|
updateOrganization,
|
|
updateOrganizationPlan
|
|
} from '@cio/db/queries/organization';
|
|
import {
|
|
getCoursesById,
|
|
getCoursesBySiteName,
|
|
getCoursesBySiteNameForSetup,
|
|
getExercisesBySiteName,
|
|
getLessonsBySiteName
|
|
} from '@cio/db/queries/course';
|
|
|
|
import { createOrganizationWithOwner } from '@api/services/onboarding';
|
|
import { env } from '@api/config/env';
|
|
import { sendEmail } from '@cio/email';
|
|
|
|
/**
|
|
* Creates a new organization with the current user as owner
|
|
* @param profileId - The profile ID of the user creating the organization
|
|
* @param data - Organization creation data (name, siteName)
|
|
* @returns Created organization, member, and updated organizations list
|
|
*/
|
|
export async function createOrg(profileId: string, data: { name: string; siteName: string }) {
|
|
try {
|
|
// Reuse existing function from onboarding service
|
|
return await createOrganizationWithOwner(profileId, {
|
|
orgName: data.name,
|
|
siteName: data.siteName
|
|
});
|
|
} catch (error) {
|
|
// Re-throw AppError as-is
|
|
if (error instanceof AppError) {
|
|
throw error;
|
|
}
|
|
// Wrap unexpected errors
|
|
throw new AppError(
|
|
error instanceof Error ? error.message : 'Failed to create organization',
|
|
ErrorCodes.ORG_CREATE_FAILED,
|
|
500
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets organizations with optional filters
|
|
* @param filters - Filter options (siteName, customDomain, isCustomDomainVerified)
|
|
* @returns Array of organizations with plans
|
|
*/
|
|
export async function getOrganizationsWithFilters(filters?: {
|
|
siteName?: string;
|
|
customDomain?: string;
|
|
isCustomDomainVerified?: boolean;
|
|
}) {
|
|
try {
|
|
const organizations = await getOrganizations(filters);
|
|
return organizations;
|
|
} catch (error) {
|
|
throw new AppError(
|
|
error instanceof Error ? error.message : 'Failed to fetch organizations',
|
|
ErrorCodes.ORGANIZATION_NOT_FOUND,
|
|
500
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets organization team members (non-students)
|
|
* @param orgId - The organization ID
|
|
* @returns Array of team members
|
|
*/
|
|
export async function getOrgTeam(orgId: string) {
|
|
try {
|
|
const team = await getOrganizationTeam(orgId);
|
|
return team;
|
|
} catch (error) {
|
|
throw new AppError(
|
|
error instanceof Error ? error.message : 'Failed to fetch organization team',
|
|
ErrorCodes.ORG_TEAM_FETCH_FAILED,
|
|
500
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets organization audience (students)
|
|
* @param orgId - The organization ID
|
|
* @returns Array of student profiles
|
|
*/
|
|
export async function getOrgAudience(orgId: string) {
|
|
try {
|
|
const audience = await getOrganizationAudience(orgId);
|
|
return audience;
|
|
} catch (error) {
|
|
throw new AppError(
|
|
error instanceof Error ? error.message : 'Failed to fetch organization audience',
|
|
ErrorCodes.ORG_AUDIENCE_FETCH_FAILED,
|
|
500
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets courses by organization siteName
|
|
* @param siteName - The organization site name
|
|
* @returns Array of courses
|
|
*/
|
|
export async function getCoursesByOrgSiteName(siteName: string) {
|
|
try {
|
|
const courses = await getCoursesBySiteName(siteName);
|
|
return courses;
|
|
} catch (error) {
|
|
throw new AppError(
|
|
error instanceof Error ? error.message : 'Failed to fetch courses',
|
|
ErrorCodes.COURSES_FETCH_FAILED,
|
|
500
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets courses by organization orgId
|
|
* @param orgId - The organization orgId
|
|
* @returns Array of courses
|
|
*/
|
|
export async function getCoursesByOrgId(orgId: string) {
|
|
try {
|
|
return getCoursesById(orgId);
|
|
} catch (error) {
|
|
throw new AppError(
|
|
error instanceof Error ? error.message : 'Failed to fetch courses',
|
|
ErrorCodes.COURSES_FETCH_FAILED,
|
|
500
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets setup data for an organization
|
|
* @param siteName - The organization site name
|
|
* @returns Setup data including courses, lessons, exercises, and organization info
|
|
*/
|
|
export async function getOrgSetupData(siteName: string) {
|
|
try {
|
|
const [organization, courses, lessons, exercises] = await Promise.all([
|
|
getOrganizationBySiteName(siteName),
|
|
getCoursesBySiteNameForSetup(siteName),
|
|
getLessonsBySiteName(siteName),
|
|
getExercisesBySiteName(siteName)
|
|
]);
|
|
|
|
// Check if course is published
|
|
const publishedCourse = courses.find((course) => course.isPublished === true);
|
|
|
|
// Get organization avatar URL directly from organization
|
|
const orgHasAvatarUrl = !!organization?.avatarUrl;
|
|
|
|
return {
|
|
isCoursePublished: !!publishedCourse,
|
|
isCourseCreated: courses.length > 0,
|
|
orgHasAvatarUrl,
|
|
courseData: courses,
|
|
lessonData: lessons,
|
|
isLessonCreated: lessons.length > 0,
|
|
isExerciseCreated: exercises.length > 0
|
|
};
|
|
} catch (error) {
|
|
throw new AppError(
|
|
error instanceof Error ? error.message : 'Failed to fetch setup data',
|
|
ErrorCodes.INTERNAL_ERROR,
|
|
500
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a new organization plan
|
|
* @param data - Organization plan creation data
|
|
* @returns Created organization plan
|
|
*/
|
|
export async function createOrgPlan(data: TNewOrganizationPlan) {
|
|
try {
|
|
const plan = await createOrganizationPlan({
|
|
orgId: data.orgId,
|
|
planName: data.planName,
|
|
subscriptionId: data.subscriptionId,
|
|
triggeredBy: data.triggeredBy,
|
|
payload: data.payload,
|
|
isActive: true,
|
|
provider: 'polar'
|
|
});
|
|
return plan;
|
|
} catch (error) {
|
|
throw new AppError(
|
|
error instanceof Error ? error.message : 'Failed to create organization plan',
|
|
ErrorCodes.ORG_PLAN_CREATE_FAILED,
|
|
500
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates an organization
|
|
* @param orgId - The organization ID
|
|
* @param data - Partial organization data to update
|
|
* @returns Updated organization
|
|
*/
|
|
export async function updateOrg(orgId: string, data: Partial<TOrganization>) {
|
|
try {
|
|
const organization = await updateOrganization(orgId, data);
|
|
if (!organization) {
|
|
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
|
|
}
|
|
return organization;
|
|
} catch (error) {
|
|
if (error instanceof AppError) {
|
|
throw error;
|
|
}
|
|
throw new AppError(
|
|
error instanceof Error ? error.message : 'Failed to update organization',
|
|
ErrorCodes.INTERNAL_ERROR,
|
|
500
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates an organization plan
|
|
* @param subscriptionId - Subscription ID
|
|
* @param payload - Payload data to update
|
|
* @returns Updated organization plan
|
|
*/
|
|
export async function updateOrgPlan(subscriptionId: string, payload: TOrganizationPlan['payload']) {
|
|
try {
|
|
const plan = await updateOrganizationPlan(subscriptionId, payload);
|
|
if (!plan) {
|
|
throw new AppError('Organization plan not found', ErrorCodes.ORG_PLAN_NOT_FOUND, 404);
|
|
}
|
|
return plan;
|
|
} catch (error) {
|
|
if (error instanceof AppError) {
|
|
throw error;
|
|
}
|
|
throw new AppError(
|
|
error instanceof Error ? error.message : 'Failed to update organization plan',
|
|
ErrorCodes.ORG_PLAN_UPDATE_FAILED,
|
|
500
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancels an organization plan
|
|
* @param subscriptionId - Subscription ID
|
|
* @param payload - Payload data to update
|
|
* @returns Updated organization plan
|
|
*/
|
|
export async function cancelOrgPlan(subscriptionId: string, payload: TOrganizationPlan['payload']) {
|
|
try {
|
|
const plan = await cancelOrganizationPlan(subscriptionId, payload);
|
|
if (!plan) {
|
|
throw new AppError('Organization plan not found', ErrorCodes.ORG_PLAN_NOT_FOUND, 404);
|
|
}
|
|
return plan;
|
|
} catch (error) {
|
|
if (error instanceof AppError) {
|
|
throw error;
|
|
}
|
|
|
|
throw new AppError(
|
|
error instanceof Error ? error.message : 'Failed to cancel organization plan',
|
|
ErrorCodes.ORG_PLAN_CANCEL_FAILED,
|
|
500
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Invites team members to an organization
|
|
* @param orgId - The organization ID
|
|
* @param emails - Array of email addresses to invite
|
|
* @param roleId - Role ID to assign (ADMIN or TUTOR)
|
|
* @returns Array of created members
|
|
*/
|
|
export async function inviteTeamMembers(orgId: string, emails: string[], roleId: number) {
|
|
const organization = await getOrganizationById(orgId);
|
|
if (!organization) {
|
|
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
|
|
}
|
|
|
|
const normalizedEmails = [...new Set(emails.map((email) => email.toLowerCase().trim()))];
|
|
|
|
const existingEmails = await checkEmailsExistInOrg(orgId, normalizedEmails);
|
|
const emailsToInvite = normalizedEmails.filter((email) => !existingEmails.includes(email));
|
|
|
|
if (emailsToInvite.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const members = await createOrganizationMembers(
|
|
emailsToInvite.map((email) => ({
|
|
organizationId: orgId,
|
|
email,
|
|
roleId,
|
|
verified: false
|
|
}))
|
|
);
|
|
|
|
const baseUrl = env.NODE_ENV === 'development' ? 'http://localhost:5173' : 'https://app.classroomio.com';
|
|
|
|
for (const email of emailsToInvite) {
|
|
try {
|
|
const inviteData = JSON.stringify({
|
|
email,
|
|
orgId: organization.id,
|
|
orgSiteName: organization.siteName
|
|
});
|
|
const inviteLink = `${baseUrl}/invite/t/${encodeURIComponent(btoa(inviteData))}`;
|
|
|
|
await sendEmail('inviteTeacher', {
|
|
to: email,
|
|
fields: {
|
|
email,
|
|
orgName: organization.name,
|
|
orgSiteName: organization.siteName,
|
|
inviteLink
|
|
}
|
|
});
|
|
} catch (emailError) {
|
|
console.error(`Failed to send invite email to ${email}:`, emailError);
|
|
}
|
|
}
|
|
|
|
return members;
|
|
} catch (error) {
|
|
console.error('Error inviting team members:', error);
|
|
throw new AppError(
|
|
error instanceof Error ? error.message : 'Failed to invite team members',
|
|
ErrorCodes.ORG_TEAM_INVITE_FAILED,
|
|
500
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes a team member from an organization
|
|
* @param orgId - The organization ID
|
|
* @param memberId - The member ID to remove
|
|
* @returns Deleted member
|
|
*/
|
|
export async function removeTeamMember(orgId: string, memberId: number) {
|
|
try {
|
|
const deleted = await deleteOrganizationMember(orgId, memberId);
|
|
if (!deleted) {
|
|
throw new AppError('Team member not found', ErrorCodes.ORG_TEAM_REMOVE_FAILED, 404);
|
|
}
|
|
|
|
return deleted;
|
|
} catch (error) {
|
|
if (error instanceof AppError) {
|
|
throw error;
|
|
}
|
|
throw new AppError(
|
|
error instanceof Error ? error.message : 'Failed to remove team member',
|
|
ErrorCodes.ORG_TEAM_REMOVE_FAILED,
|
|
500
|
|
);
|
|
}
|
|
}
|