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:
+3
-1
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './media';
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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,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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user