feat: redo settings and all org pages. (#601)
* feat: new components, deploy storybook * refactor: simplify component field definitions and enhance button loading state - Removed unnecessary type imports and simplified the FIELDS array definitions across multiple components in Storybook. - Updated button component to use a loading state instead of isLoading, improving clarity and consistency in props. - Adjusted button stories to reflect the new loading prop and enhanced the button's loading visual feedback. - Minor CSS adjustments for better layout and styling consistency. * chore: add field, card, password components * feat: improve page component & update AuthUI path * chore: update bits-ui to version 2.14.4 and all org related UI's - Updated bits-ui dependency in pnpm-lock.yaml and package.json to version 2.14.4. - Refactored imports for RoleBasedSecurity and UpgradeBanner components to use the new ui feature directory. - Removed unused Course components and related files to clean up the codebase. - Enhanced various components to improve consistency and maintainability. * chore: move all setting buttons to the top and update auth ui styles * chore: remove svelte-loading-spinners and replace with @cio/ui/base/spinner - Removed all instances of svelte-loading-spinners from the project. - Updated components to use the new Spinner from @cio/ui/base/spinner. - Cleaned up pnpm-lock.yaml and package.json to reflect the removal of svelte-loading-spinners. - Added new CSS animations for rocket launch effects in app.css. - Enhanced email change flow with updated verification messages in translations. * chore: remove supabase file upload from dashboard and move to r2 * chore: update organization API and UI components - Added new API methods for organization plan operations and team management. - Introduced middleware for API key authentication and organization admin checks. - Refactored organization-related components to utilize the new API structure. - Updated various UI components to improve consistency and maintainability. - Enhanced error handling and validation for organization-related actions. - Removed deprecated services and streamlined the codebase.
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
export * from './account';
|
||||
export * from './media';
|
||||
export * from './onboarding';
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { BUCKET_NAME, MAX_IMAGE_SIZE } from '@api/constants/upload';
|
||||
|
||||
import { ALLOWED_IMAGE_TYPES } from '@cio/utils/validation';
|
||||
import { env } from '@api/config/env';
|
||||
import { generateFileKey } from '@api/utils/upload';
|
||||
import { uploadToS3 } from '@api/utils/s3';
|
||||
|
||||
/**
|
||||
* Uploads an image file to S3 and returns the public URL
|
||||
* @param file - The image file to upload
|
||||
* @returns Object containing the public URL and file key
|
||||
*/
|
||||
export async function uploadImage(file: File) {
|
||||
if (!env.CLOUDFLARE_IMAGE_BUCKET_DOMAIN) {
|
||||
throw new AppError(new Error('Image bucket domain not configured'), ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!ALLOWED_IMAGE_TYPES.includes(file.type as any)) {
|
||||
throw new AppError(
|
||||
new Error(`Invalid file type. Allowed types: ${ALLOWED_IMAGE_TYPES.join(', ')}`),
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_IMAGE_SIZE) {
|
||||
throw new AppError(
|
||||
new Error(`File size exceeds maximum of ${MAX_IMAGE_SIZE / 1024 / 1024}MB`),
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
// Generate unique file key
|
||||
const fileKey = generateFileKey(file.name);
|
||||
|
||||
// Convert File to Buffer
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Upload to S3
|
||||
const uploadResult = await uploadToS3({
|
||||
Bucket: BUCKET_NAME.MEDIA,
|
||||
Key: fileKey,
|
||||
Body: buffer,
|
||||
ContentType: file.type,
|
||||
CacheControl: 'public, max-age=31536000' // Cache for 1 year
|
||||
});
|
||||
|
||||
if (!uploadResult.success) {
|
||||
throw new AppError(new Error(uploadResult.error || 'Failed to upload image'), ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
// Construct public URL
|
||||
const publicUrl = `${env.CLOUDFLARE_IMAGE_BUCKET_DOMAIN}/${fileKey}`;
|
||||
|
||||
return {
|
||||
url: publicUrl,
|
||||
fileKey
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,56 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type { TNewOrganizationPlan, TOrganizationPlan, TPlan } from '@db/types';
|
||||
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 {
|
||||
getCoursesBySiteName,
|
||||
getCoursesBySiteNameForSetup,
|
||||
getExercisesBySiteName,
|
||||
getLessonsBySiteName
|
||||
} from '@cio/db/queries/course';
|
||||
|
||||
import { getCoursesBySiteName } 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
|
||||
@@ -87,6 +128,44 @@ export async function getCoursesByOrgSiteName(siteName: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -113,6 +192,31 @@ export async function createOrgPlan(data: TNewOrganizationPlan) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -163,3 +267,97 @@ export async function cancelOrgPlan(subscriptionId: string, payload: TOrganizati
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user