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:
Rotimi Best
2025-12-02 09:54:38 +01:00
committed by GitHub
parent 531516a8f7
commit c7854efae3
350 changed files with 13928 additions and 3895 deletions
+3 -1
View File
@@ -9,6 +9,7 @@ import { cors } from 'hono/cors';
import { courseRouter } from '@api/routes/course';
import { logger } from 'hono/logger';
import { mailRouter } from '@api/routes/mail';
import { mediaRouter } from '@api/routes/media';
import { onboardingRouter } from '@api/routes/onboarding';
import { organizationRouter } from '@api/routes/organization';
import { prettyJSON } from 'hono/pretty-json';
@@ -26,7 +27,7 @@ export const app = new Hono()
cors({
origin: TRUSTED_ORIGINS,
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
allowHeaders: ['Content-Type', 'Authorization', 'Cio-org-id'],
exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
maxAge: 600,
credentials: true
@@ -71,6 +72,7 @@ export const app = new Hono()
.route('/account', accountRouter)
.route('/course', courseRouter)
.route('/mail', mailRouter)
.route('/media', mediaRouter)
.route('/organization', organizationRouter)
// Error handling
+3 -1
View File
@@ -6,6 +6,7 @@ const envSchema = z.object({
CLOUDFLARE_BUCKET_DOMAIN: z.string().optional(),
CLOUDFLARE_RENDERING_API_KEY: z.string().optional(),
CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(),
CLOUDFLARE_IMAGE_BUCKET_DOMAIN: z.string().optional(),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
OPENAPI_URL: z.string().optional(),
PORT: z.string().optional(),
@@ -20,7 +21,8 @@ const envSchema = z.object({
SMTP_SENDER: z.string().optional(),
SMTP_USER: z.string().optional(),
ZOHO_TOKEN: z.string().optional(),
TRUSTED_ORIGINS: z.string().optional()
TRUSTED_ORIGINS: z.string().optional(),
SERVER_KEY: z.string().optional()
});
export const env = envSchema.parse(process.env);
+5 -14
View File
@@ -1,20 +1,11 @@
export const ALLOWED_CONTENT_TYPES = [
'video/mp4',
'video/quicktime',
'video/x-msvideo',
'video/x-matroska'
] as const;
export const ALLOWED_DOCUMENT_TYPES = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
'application/msword' // .doc
] as const;
// API-specific constants (file size limits)
export const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
export const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
// API-specific constants (bucket names)
export const BUCKET_NAME = {
VIDEOS: 'videos',
DOCUMENTS: 'documents'
DOCUMENTS: 'documents',
MEDIA: 'media'
} as const;
+58
View File
@@ -0,0 +1,58 @@
import { Context, Next } from 'hono';
import { env } from '@api/config/env';
/**
* Middleware to authenticate requests using API key
* Checks for Authorization: Bearer <api-key> header
* Used for server-to-server communication (webhooks, etc.)
*/
export const apiKeyMiddleware = async (c: Context, next: Next) => {
try {
const authHeader = c.req.header('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return c.json(
{
success: false,
message: 'Unauthorized: Missing or invalid API key'
},
401
);
}
const apiKey = authHeader.replace('Bearer ', '').trim();
const expectedApiKey = env.SERVER_KEY;
if (!expectedApiKey) {
console.error('SERVER_KEY not configured in environment');
return c.json(
{
success: false,
message: 'Server configuration error'
},
500
);
}
if (apiKey !== expectedApiKey) {
return c.json(
{
success: false,
message: 'Unauthorized: Invalid API key'
},
401
);
}
// API key is valid, continue
await next();
} catch (error) {
return c.json(
{
success: false,
message: 'Unauthorized'
},
401
);
}
};
@@ -0,0 +1,34 @@
import { Context, Next } from 'hono';
import { apiKeyMiddleware } from './api-key';
import { authMiddleware } from './auth';
/**
* Middleware that accepts either user authentication OR API key
* Used for endpoints that can be accessed by:
* 1. Authenticated users (via session cookies)
* 2. Server-to-server calls (via API key)
*/
export const authOrApiKeyMiddleware = async (c: Context, next: Next) => {
const user = c.get('user');
const session = c.get('session');
if (user && session) {
await next();
return;
}
const authHeader = c.req.header('Authorization');
if (authHeader && authHeader.startsWith('Bearer ')) {
return apiKeyMiddleware(c, next);
}
return c.json(
{
success: false,
message: 'Unauthorized: Authentication required (session or API key)'
},
401
);
};
+62
View File
@@ -0,0 +1,62 @@
import { Context, Next } from 'hono';
import { ErrorCodes } from '@api/utils/errors';
import { isUserOrgAdmin } from '@cio/db/queries/organization';
/**
* Middleware to check if the authenticated user is an admin of the organization
* Requires authMiddleware to be applied first
* Expects orgId in the 'cio-org-id' header
*/
export const orgAdminMiddleware = async (c: Context, next: Next) => {
try {
const user = c.get('user');
if (!user) {
return c.json(
{
success: false,
error: 'Unauthorized',
code: 'UNAUTHORIZED'
},
401
);
}
const orgId = c.req.header('cio-org-id');
if (!orgId) {
return c.json(
{
success: false,
error: 'Organization ID is required',
code: 'ORG_ID_REQUIRED'
},
400
);
}
const isAdmin = await isUserOrgAdmin(orgId, user.id);
if (!isAdmin) {
return c.json(
{
success: false,
error: 'Only organization admins can perform this action',
code: ErrorCodes.ORG_TEAM_NOT_AUTHORIZED
},
403
);
}
await next();
} catch (error) {
console.error('Error in orgAdminMiddleware:', error);
return c.json(
{
success: false,
error: 'Failed to verify organization admin status',
code: 'ORG_ADMIN_CHECK_FAILED'
},
500
);
}
};
-1
View File
@@ -8,7 +8,6 @@ export const mailRouter = new Hono().post('/send', zValidator('json', ZDeliverEm
const validatedData = c.req.valid('json');
try {
// Use service to send emails
const results = await deliverEmail(validatedData);
const hasErrors = results.some((result) => !result.success);
+1
View File
@@ -0,0 +1 @@
export * from './media';
+29
View File
@@ -0,0 +1,29 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { uploadImage } from '@api/services/media';
export const mediaRouter = new Hono().post('/image/upload', authMiddleware, async (c) => {
try {
const body = await c.req.parseBody();
const file = body.file;
if (!file || !(file instanceof File)) {
throw new AppError(new Error('No file provided'), ErrorCodes.VALIDATION_ERROR, 400);
}
const result = await uploadImage(file);
return c.json({
success: true,
url: result.url,
fileKey: result.fileKey,
message: 'Image uploaded successfully'
});
} catch (error) {
return handleError(c, error, 'Failed to upload image');
}
});
+173 -13
View File
@@ -1,25 +1,39 @@
import {
ZCancelOrgPlan,
ZCreateOrgPlan,
ZCreateOrganization,
ZGetCoursesBySiteName,
ZGetOrgSetup,
ZGetOrganizationAudience,
ZGetOrganizationTeam,
ZGetOrganizations,
ZUpdateOrgPlan
ZInviteTeamMembers,
ZRemoveTeamMember,
ZUpdateOrgPlan,
ZUpdateOrganization,
ZUpdateOrganizationParam
} from '@cio/utils/validation/organization';
import {
cancelOrgPlan,
createOrg,
createOrgPlan,
getCoursesByOrgSiteName,
getOrgAudience,
getOrgSetupData,
getOrgTeam,
getOrganizationsWithFilters,
inviteTeamMembers,
removeTeamMember,
updateOrg,
updateOrgPlan
} from '@api/services/organization';
import { Hono } from '@api/utils/hono';
import { TOrganization } from '@db/types';
import { authMiddleware } from '@api/middlewares/auth';
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
import { handleError } from '@api/utils/errors';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { zValidator } from '@hono/zod-validator';
export const organizationRouter = new Hono()
@@ -27,10 +41,12 @@ export const organizationRouter = new Hono()
* GET /organization
* Gets organizations with optional filters
* Query params: siteName (string, optional), customDomain (string, optional), isCustomDomainVerified (boolean, optional)
* Accepts: User session OR API key (for server-side routes)
*/
.get('/', zValidator('query', ZGetOrganizations), async (c) => {
.get('/', authOrApiKeyMiddleware, zValidator('query', ZGetOrganizations), async (c) => {
try {
const filters = c.req.valid('query');
console.log('filters', filters);
const organizations = await getOrganizationsWithFilters(filters);
return c.json(
@@ -41,6 +57,7 @@ export const organizationRouter = new Hono()
200
);
} catch (error) {
console.error('Error fetching organizations:', error);
return handleError(c, error, 'Failed to fetch organizations');
}
})
@@ -65,6 +82,63 @@ export const organizationRouter = new Hono()
return handleError(c, error, 'Failed to fetch organization team');
}
})
/**
* POST /organization/:orgId/team/invite
* Invites team members to an organization
* Requires authentication and admin role
*/
.post(
'/:orgId/team/invite',
authMiddleware,
orgAdminMiddleware,
zValidator('param', ZGetOrganizationTeam),
zValidator('json', ZInviteTeamMembers),
async (c) => {
try {
const { orgId } = c.req.valid('param');
const { emails, roleId } = c.req.valid('json');
const members = await inviteTeamMembers(orgId, emails, roleId);
return c.json(
{
success: true,
data: members
},
201
);
} catch (error) {
return handleError(c, error, 'Failed to invite team members');
}
}
)
/**
* DELETE /organization/:orgId/team/:memberId
* Removes a team member from an organization
* Requires authentication and admin role
*/
.delete(
'/:orgId/team/:memberId',
authMiddleware,
orgAdminMiddleware,
zValidator('param', ZRemoveTeamMember),
async (c) => {
try {
const { orgId, memberId } = c.req.valid('param');
await removeTeamMember(orgId, memberId);
return c.json(
{
success: true
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to remove team member');
}
}
)
/**
* GET /organization/:orgId/audience
* Gets organization audience (students)
@@ -91,28 +165,58 @@ export const organizationRouter = new Hono()
* Gets courses by organization siteName
* Query params: siteName (string)
*/
.get('/courses', zValidator('query', ZGetCoursesBySiteName), async (c) => {
.get(
'/courses',
// TODO: Ratelimit this endpoint
// TODO: Add cache
// TODO: Add pagination
// TODO: Add sorting
// TODO: Add filtering
zValidator('query', ZGetCoursesBySiteName),
async (c) => {
try {
const { siteName } = c.req.valid('query');
const courses = await getCoursesByOrgSiteName(siteName);
return c.json(
{
success: true,
data: courses
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch courses');
}
}
)
/**
* GET /organization/setup
* Gets setup data for an organization (courses, lessons, exercises, org info)
* Query params: siteName (string)
*/
.get('/setup', zValidator('query', ZGetOrgSetup), async (c) => {
try {
const { siteName } = c.req.valid('query');
const courses = await getCoursesByOrgSiteName(siteName);
const setupData = await getOrgSetupData(siteName);
return c.json(
{
success: true,
data: courses
data: setupData
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch courses');
return handleError(c, error, 'Failed to fetch setup data');
}
})
/**
* POST /organization/plan
* Creates a new organization plan
* Requires authentication
* Requires authentication (user session or API key)
*/
.post('/plan', authMiddleware, zValidator('json', ZCreateOrgPlan), async (c) => {
.post('/plan', authOrApiKeyMiddleware, zValidator('json', ZCreateOrgPlan), async (c) => {
try {
const data = c.req.valid('json');
const plan = await createOrgPlan(data);
@@ -131,9 +235,9 @@ export const organizationRouter = new Hono()
/**
* PUT /organization/plan
* Updates an organization plan by subscription ID
* Requires authentication
* Requires authentication (user session or API key)
*/
.put('/plan', authMiddleware, zValidator('json', ZUpdateOrgPlan), async (c) => {
.put('/plan', authOrApiKeyMiddleware, zValidator('json', ZUpdateOrgPlan), async (c) => {
try {
const { subscriptionId, payload } = c.req.valid('json');
const plan = await updateOrgPlan(subscriptionId, payload);
@@ -152,9 +256,9 @@ export const organizationRouter = new Hono()
/**
* POST /organization/plan/cancel
* Cancels an organization plan by subscription ID
* Requires authentication
* Requires authentication (user session or API key)
*/
.post('/plan/cancel', authMiddleware, zValidator('json', ZCancelOrgPlan), async (c) => {
.post('/plan/cancel', authOrApiKeyMiddleware, zValidator('json', ZCancelOrgPlan), async (c) => {
try {
const { subscriptionId, payload } = c.req.valid('json');
const plan = await cancelOrgPlan(subscriptionId, payload);
@@ -169,4 +273,60 @@ export const organizationRouter = new Hono()
} catch (error) {
return handleError(c, error, 'Failed to cancel organization plan');
}
});
})
/**
* POST /organization
* Creates a new organization with the current user as owner
* Requires authentication
*/
.post('/', authMiddleware, zValidator('json', ZCreateOrganization), async (c) => {
try {
const user = c.get('user');
const data = c.req.valid('json');
const result = await createOrg(user.id, data);
return c.json(
{
success: true,
data: result
},
201
);
} catch (error) {
return handleError(c, error, 'Failed to create organization');
}
})
/**
* PUT /organization/:orgId
* Updates an organization (name, avatarUrl)
* Requires authentication
*/
.put(
'/:orgId',
authMiddleware,
zValidator('param', ZUpdateOrganizationParam),
zValidator('json', ZUpdateOrganization),
async (c) => {
try {
console.log('updateOrg');
console.log(c.req.valid('param'));
console.log(c.req.valid('json'));
const { orgId } = c.req.valid('param');
const data = c.req.valid('json') as Partial<TOrganization>;
const organization = await updateOrg(orgId, data);
return c.json(
{
success: true,
data: organization
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to update organization');
}
}
);
+1
View File
@@ -1,2 +1,3 @@
export * from './account';
export * from './media';
export * from './onboarding';
+64
View File
@@ -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
};
}
+200 -2
View File
@@ -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
);
}
}
+4
View File
@@ -40,6 +40,10 @@ export const ErrorCodes = {
ORG_PLAN_UPDATE_FAILED: 'ORG_PLAN_UPDATE_FAILED',
ORG_PLAN_CANCEL_FAILED: 'ORG_PLAN_CANCEL_FAILED',
ORG_PLAN_NOT_FOUND: 'ORG_PLAN_NOT_FOUND',
ORG_TEAM_INVITE_FAILED: 'ORG_TEAM_INVITE_FAILED',
ORG_TEAM_REMOVE_FAILED: 'ORG_TEAM_REMOVE_FAILED',
ORG_TEAM_EMAIL_EXISTS: 'ORG_TEAM_EMAIL_EXISTS',
ORG_TEAM_NOT_AUTHORIZED: 'ORG_TEAM_NOT_AUTHORIZED',
// Profile errors
PROFILE_UPDATE_FAILED: 'PROFILE_UPDATE_FAILED',
+3 -6
View File
@@ -22,11 +22,10 @@ export const s3Client = new S3Client({
}
});
export async function uploadToS3(
params: PutObjectCommandInput
): Promise<{ success: boolean; error?: string }> {
export async function uploadToS3(params: PutObjectCommandInput): Promise<{ success: boolean; error?: string }> {
try {
await s3Client.send(new PutObjectCommand(params));
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
@@ -44,9 +43,7 @@ export async function getFromS3(
}
}
export async function deleteFromS3(
params: DeleteObjectCommandInput
): Promise<{ success: boolean; error?: string }> {
export async function deleteFromS3(params: DeleteObjectCommandInput): Promise<{ success: boolean; error?: string }> {
try {
await s3Client.send(new DeleteObjectCommand(params));
return { success: true };
+11 -1
View File
@@ -11,6 +11,13 @@ export function getExtension(filename: string): string {
return ext.length > 1 ? ext[ext.length - 1] : '';
}
export function removeSpacesAndSpecialCharacters(fileName: string): string {
return fileName
.replace(/\s+/g, '-')
.replace(/[^a-zA-Z0-9.-]/g, '')
.toLowerCase();
}
/**
* Generates a unique file key for storage, preserving the file extension.
* Throws an error if the filename is invalid or has no extension.
@@ -20,5 +27,8 @@ export function generateFileKey(fileName: string): string {
if (!ext) {
throw new Error('Invalid file name or missing file extension');
}
return `${nanoid()}.${ext}`;
const cleanedFileName = removeSpacesAndSpecialCharacters(fileName);
return `${nanoid()}-${cleanedFileName}`;
}