Files
classroomio/apps/api/src/routes/organization/organization.ts
T

670 lines
19 KiB
TypeScript

import {
ZAssignAudienceCourses,
ZAudienceInviteByEmail,
ZCancelOrgPlan,
ZCreateOrgPlan,
ZCreateOrganization,
ZGetAudienceQuery,
ZGetCoursesBySiteName,
ZGetOrgSetup,
ZGetOrganizationCoursesQuery,
ZGetOrganizations,
ZGetUserAnalytics,
ZImportAudienceMembers,
ZInviteTeamMembers,
ZLMSExercisesParam,
ZRemoveTeamMember,
ZUpdateOrgPlan,
ZUpdateOrganization
} from '@cio/utils/validation/organization';
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
import {
assignAudienceToCourses,
importAudienceMembers,
resendAudienceInvite,
revokeAudiencePendingInvite
} from '@api/services/organization/audience';
import {
cancelOrgPlan,
createOrg,
createOrgPlan,
getFirstOrgForSelfHosted,
getOrgAudience,
getOrgSetupData,
getOrgTeam,
getOrganizationCourses,
getOrganizationsWithFilters,
getPublicCourses,
getRecommendedCourses,
getUserAnalytics,
getUserEnrolledCourses,
removeAudienceMember,
removeTeamMember,
updateOrg,
updateOrgPlan
} from '@api/services/organization';
import { Hono } from '@api/utils/hono';
import { ROLE } from '@cio/utils/constants';
import { TOrganization } from '@db/types';
import { assetsRouter } from '@api/routes/organization/assets';
import { authMiddleware } from '@api/middlewares/auth';
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { automationRouter } from '@api/routes/organization/automation';
import { courseImportRouter } from '@api/routes/organization/course-import';
import { getLMSExercisesService } from '@api/services/exercise';
import { handleError } from '@api/utils/errors';
import { inviteTeamMembers } from '@api/services/organization/invite';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { orgMemberOrAutomationKeyMiddleware } from '@api/middlewares/org-member-or-automation-key';
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
import { quizRouter } from '@api/routes/organization/quiz';
import { tagsRouter } from '@api/routes/organization/tags';
import { zValidator } from '@hono/zod-validator';
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('/', authOrApiKeyMiddleware, zValidator('query', ZGetOrganizations), async (c) => {
try {
const filters = c.req.valid('query');
const organizations = await getOrganizationsWithFilters(filters);
return c.json(
{
success: true,
data: organizations
},
200
);
} catch (error) {
console.error('Error fetching organizations:', error);
return handleError(c, error, 'Failed to fetch organizations');
}
})
/**
* GET /organization/first
* Gets the first organization (for self-hosted single-org mode). Requires API key.
*/
.get('/first', authOrApiKeyMiddleware, async (c) => {
try {
const org = await getFirstOrgForSelfHosted();
return c.json(
{
success: true,
data: org ? [org] : []
},
200
);
} catch (error) {
console.error('Error fetching first organization:', error);
return handleError(c, error, 'Failed to fetch first organization');
}
})
/**
* GET /organization/team
* Gets organization team members (non-students)
* Requires authentication
*/
.get('/team', authMiddleware, orgTeamMemberMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const team = await getOrgTeam(orgId);
return c.json(
{
success: true,
data: team
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch organization team');
}
})
/**
* POST /organization/team/invite
* Invites team members to an organization
* Requires authentication and admin role
*/
.post('/team/invite', authMiddleware, orgAdminMiddleware, zValidator('json', ZInviteTeamMembers), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { emails, roleId } = c.req.valid('json');
const members = await inviteTeamMembers(orgId, emails, roleId, user.id);
return c.json(
{
success: true,
data: members
},
201
);
} catch (error) {
return handleError(c, error, 'Failed to invite team members');
}
})
/**
* DELETE /organization/team/:memberId
* Removes a team member from an organization
* Requires authentication and admin role
*/
.delete('/team/:memberId', authMiddleware, orgAdminMiddleware, zValidator('param', ZRemoveTeamMember), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { memberId } = c.req.valid('param');
await removeTeamMember(orgId, memberId, user.id);
return c.json(
{
success: true
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to remove team member');
}
})
/**
* GET /organization/audience
* Gets organization audience (students)
* Requires authentication
*/
.get('/audience', authMiddleware, orgTeamMemberMiddleware, zValidator('query', ZGetAudienceQuery), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const query = c.req.valid('query');
const audience = await getOrgAudience(orgId, query);
return c.json(
{
success: true,
data: audience.items,
pagination: audience.pagination,
query: audience.query
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch organization audience');
}
})
/**
* DELETE /organization/audience/:memberId
* Removes a student from the organization
*/
.delete(
'/audience/:memberId',
authMiddleware,
orgAdminMiddleware,
zValidator('param', ZRemoveTeamMember),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { memberId } = c.req.valid('param');
const deleted = await removeAudienceMember(orgId, memberId, user.id);
return c.json(
{
success: true,
data: deleted
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to remove audience member');
}
}
)
/**
* POST /organization/audience/resend-invite
* Resends a student org invite email (new token; optional course metadata from last invite)
*/
.post(
'/audience/resend-invite',
authMiddleware,
orgTeamMemberMiddleware,
zValidator('json', ZAudienceInviteByEmail),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const data = c.req.valid('json');
const result = await resendAudienceInvite(orgId, data, user.id);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to resend audience invite');
}
}
)
/**
* POST /organization/audience/revoke-invite
* Revokes an active (non-expired) pending student org invite
*/
.post(
'/audience/revoke-invite',
authMiddleware,
orgTeamMemberMiddleware,
zValidator('json', ZAudienceInviteByEmail),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const data = c.req.valid('json');
const result = await revokeAudiencePendingInvite(orgId, data, user.id);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to revoke audience invite');
}
}
)
/**
* GET /organization/audience/:userId/analytics
* Gets user analytics for a specific user in an organization
* Requires authentication and organization membership
*/
.get(
'/audience/:userId/analytics',
authMiddleware,
orgTeamMemberMiddleware,
zValidator('param', ZGetUserAnalytics),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { userId } = c.req.valid('param');
const analytics = await getUserAnalytics(userId, orgId);
return c.json(
{
success: true,
data: analytics
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch user analytics');
}
}
)
/**
* GET /organization/courses/public
* Gets published courses for an organization (public landing page)
* Query params: siteName (string)
* No authentication required - returns only published courses
*/
.get('/courses/public', zValidator('query', ZGetCoursesBySiteName), async (c) => {
try {
const { siteName, tags } = c.req.valid('query');
const tagSlugs = tags
?.split(',')
.map((value) => value.trim())
.filter(Boolean);
const courses = await getPublicCourses(siteName, tagSlugs && tagSlugs.length > 0 ? tagSlugs : undefined);
return c.json(
{
success: true,
data: courses
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch public courses');
}
})
/**
* GET /organization/courses/enrolled
* Gets enrolled courses for a user in an organization (used in lms)
* Requires authentication and organization membership
*/
.get('/courses/enrolled', authMiddleware, orgMemberMiddleware, async (c) => {
try {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
const result = await getUserEnrolledCourses(orgId, user.id);
return c.json(
{
success: true,
data: result
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch courses');
}
})
/**
* GET /organization/courses/recommended
* Gets recommended courses (published courses user isn't enrolled in) for a user in an organization (used in lms)
* Requires authentication and organization membership
*/
.get('/courses/recommended', authMiddleware, orgMemberMiddleware, async (c) => {
try {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
const result = await getRecommendedCourses(orgId, user.id);
return c.json(
{
success: true,
data: result
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch recommended courses');
}
})
/**
* GET /organization/courses
* Gets courses for an organization with role-based filtering (used in dashboard)
* Requires authentication and organization membership
*/
.get(
'/courses',
authOrAutomationKeyMiddleware,
orgMemberOrAutomationKeyMiddleware(['course:read']),
zValidator('query', ZGetOrganizationCoursesQuery),
async (c) => {
try {
const user = c.get('user');
const orgId = c.get('orgId');
const userRole = c.get('userRole');
const automationKey = c.get('automationKey');
const query = c.req.valid('query');
if (!orgId) {
return c.json(
{
success: false,
error: 'Organization context not available',
code: 'ORG_CONTEXT_MISSING'
},
500
);
}
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'list_org_courses');
}
const result = await getOrganizationCourses(
orgId,
user?.id ?? automationKey?.createdByProfileId ?? '',
userRole ?? ROLE.ADMIN,
query
);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'list_org_courses', {
tags: query.tags,
search: query.search,
page: query.page,
limit: query.limit
});
}
return c.json(
{
success: true,
data: result.items,
pagination: result.pagination,
query: result.query
},
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 setupData = await getOrgSetupData(siteName);
return c.json(
{
success: true,
data: setupData
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch setup data');
}
})
/**
* POST /organization/plan
* Creates a new organization plan
* Requires authentication (user session or API key)
*/
.post('/plan', authOrApiKeyMiddleware, zValidator('json', ZCreateOrgPlan), async (c) => {
try {
const data = c.req.valid('json');
const plan = await createOrgPlan(data);
return c.json(
{
success: true,
data: plan
},
201
);
} catch (error) {
return handleError(c, error, 'Failed to create organization plan');
}
})
/**
* PUT /organization/plan
* Updates an organization plan by subscription ID
* Requires authentication (user session or API key)
*/
.put('/plan', authOrApiKeyMiddleware, zValidator('json', ZUpdateOrgPlan), async (c) => {
try {
const { subscriptionId, payload } = c.req.valid('json');
const plan = await updateOrgPlan(subscriptionId, payload);
return c.json(
{
success: true,
data: plan
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to update organization plan');
}
})
/**
* POST /organization/plan/cancel
* Cancels an organization plan by subscription ID
* Requires authentication (user session or API key)
*/
.post('/plan/cancel', authOrApiKeyMiddleware, zValidator('json', ZCancelOrgPlan), async (c) => {
try {
const { subscriptionId, payload } = c.req.valid('json');
const plan = await cancelOrgPlan(subscriptionId, payload);
return c.json(
{
success: true,
data: plan
},
200
);
} 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
* Updates an organization (name, avatarUrl)
* Requires authentication
*/
.put('/', authMiddleware, orgAdminMiddleware, zValidator('json', ZUpdateOrganization), async (c) => {
try {
console.log(c.req.valid('json'));
const orgId = c.req.header('cio-org-id')!;
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');
}
})
/**
* GET /organization/:orgId/exercises/lms
* Gets exercises with submissions for a student in an organization
* Returns exercises from unlocked lessons in courses where the student is a member
* Requires authentication and organization membership
*/
.get('/:orgId/exercises/lms', authMiddleware, zValidator('param', ZLMSExercisesParam), async (c) => {
try {
const { orgId } = c.req.valid('param');
const user = c.get('user')!;
// Check org membership manually since middleware expects header
const { getUserOrgRole } = await import('@cio/db/queries/organization');
const roleId = await getUserOrgRole(orgId, user.id);
if (roleId === null) {
return c.json(
{
success: false,
error: 'UNAUTHORIZED',
code: 'UNAUTHORIZED'
},
403
);
}
const exercises = await getLMSExercisesService(user.id, orgId);
return c.json(
{
success: true,
data: exercises
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch LMS exercises');
}
})
/**
* POST /organization/audience/import
* Imports users into the organization as students with optional course assignment
* Requires authentication and admin role
*/
.post(
'/audience/import',
authMiddleware,
orgTeamMemberMiddleware,
zValidator('json', ZImportAudienceMembers),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const data = c.req.valid('json');
const result = await importAudienceMembers(orgId, data, user.id);
return c.json(
{
success: true,
data: result
},
201
);
} catch (error) {
return handleError(c, error, 'Failed to import audience members');
}
}
)
/**
* POST /organization/audience/assign-courses
* Assigns existing audience members to one or more courses
* Requires authentication and admin role
*/
.post(
'/audience/assign-courses',
authMiddleware,
orgTeamMemberMiddleware,
zValidator('json', ZAssignAudienceCourses),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const data = c.req.valid('json');
const result = await assignAudienceToCourses(orgId, data);
return c.json(
{
success: true,
data: result
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to assign audience to courses');
}
}
)
.route('/automation', automationRouter)
.route('/course-import', courseImportRouter)
.route('/tags', tagsRouter)
.route('/assets', assetsRouter)
.route('/:orgId/quiz', quizRouter);