feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
+37
View File
@@ -0,0 +1,37 @@
import {
clearSecondariesReadOnly,
findAccountsOverAllowance,
setSecondariesReadOnlyUntil
} from '@cio/db/queries/account';
import { WORKSPACES_INCLUDED } from '@cio/utils/plans';
const GRACE_PERIOD_DAYS = 7;
export type GraceCheckResult = {
scanned: number;
marked: Array<{ primaryOrgId: string; planName: string | null; workspaceCount: number; secondariesAffected: number }>;
};
/**
* Daily check: any account whose total workspace count exceeds the allowance
* for its current plan gets all of its secondary workspaces marked read-only
* for the next 7 days. The primary workspace is never locked.
*/
export async function runWorkspaceGraceCheck(): Promise<GraceCheckResult> {
const offenders = await findAccountsOverAllowance(WORKSPACES_INCLUDED);
const until = new Date(Date.now() + GRACE_PERIOD_DAYS * 24 * 60 * 60 * 1000);
const marked: GraceCheckResult['marked'] = [];
for (const offender of offenders) {
const affected = await setSecondariesReadOnlyUntil(offender.primaryOrgId, until);
marked.push({ ...offender, secondariesAffected: affected });
}
return { scanned: offenders.length, marked };
}
export async function clearWorkspaceGraceForAccount(primaryOrgId: string): Promise<number> {
return clearSecondariesReadOnly(primaryOrgId);
}
+4
View File
@@ -0,0 +1,4 @@
export * from './profile';
export * from './workspaces';
export * from './quota';
export * from './grace';
+118
View File
@@ -0,0 +1,118 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
createOrganizationMember,
getFirstOrganization,
getOrganizationByProfileId,
hasOrgMemberByProfileIdOrEmail
} from '@cio/db/queries/organization';
import { getProfileByEmail, getProfileById, updateProfile } from '@cio/db/queries/auth';
import type { OrganizationWithMemberAndPlans } from '@cio/db/queries/organization/types';
import { ROLE } from '@cio/utils/constants';
import type { TProfile } from '@cio/db/types';
import { env } from '@api/config/env';
import { getLicenseStatus } from '@api/services/license';
export type GetAccountDataResult = {
profile: TProfile;
organizations: OrganizationWithMemberAndPlans[];
licenseFeatures: string[];
};
/**
* Fetches account data including profile and organizations for a user
* In self-hosted mode: auto-adds non-member users as students to the single org
* @param userId - The user ID to fetch account data for
* @returns Account data with profile and organizations
*/
export async function getAccountData(userId: string): Promise<GetAccountDataResult> {
let [profile, organizations, licenseStatus] = await Promise.all([
getProfileById(userId),
getOrganizationByProfileId(userId),
getLicenseStatus()
]);
if (!profile) {
throw new AppError(`Account not found for user ID: ${userId}`, ErrorCodes.ACCOUNT_NOT_FOUND, 404);
}
// Self-hosted: auto-add user as student to the single org if they are not a member.
// Skip if they already have membership (by profileId) or a pending invite (by email).
if (env.PUBLIC_IS_SELFHOSTED === 'true' && organizations.length === 0) {
const firstOrg = await getFirstOrganization();
if (firstOrg) {
const alreadyInOrg = await hasOrgMemberByProfileIdOrEmail(firstOrg.id, userId, profile.email ?? undefined);
if (!alreadyInOrg) {
await createOrganizationMember({
organizationId: firstOrg.id,
profileId: userId,
roleId: ROLE.STUDENT,
verified: true
});
}
organizations = await getOrganizationByProfileId(userId);
}
}
return {
profile,
organizations,
licenseFeatures: licenseStatus.features
};
}
/**
* Updates profile data for a user
* @param userId - The user ID to update profile for
* @param data - Partial profile data to update (excluding email, id, createdAt, updatedAt)
* @returns Updated profile data
*/
export async function updateUser(
userId: string,
data: Partial<Omit<TProfile, 'id' | 'email' | 'createdAt' | 'updatedAt'>>
) {
try {
const updatedProfile = await updateProfile(userId, data);
if (!updatedProfile) {
throw new AppError(`Profile not found for user ID: ${userId}`, ErrorCodes.PROFILE_NOT_FOUND, 404);
}
return updatedProfile;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
// Handle unique constraint violations
if (error instanceof Error && error.message.includes('profile_username_key')) {
throw new AppError('Username already exists', ErrorCodes.VALIDATION_ERROR, 400, 'username');
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update profile',
ErrorCodes.PROFILE_UPDATE_FAILED,
500
);
}
}
/**
* Gets profile by email
* @param email - The email address
* @returns Profile or null if not found
*/
export async function getProfileByEmailService(email: string): Promise<TProfile | null> {
try {
const profile = await getProfileByEmail(email);
return profile;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch profile by email',
ErrorCodes.PROFILE_NOT_FOUND,
500
);
}
}
+84
View File
@@ -0,0 +1,84 @@
import * as schema from '@db/schema';
import { and, count, eq, gte, inArray, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@cio/db/drizzle';
import { listAccountOrgIds } from '@cio/db/queries/account';
export type AccountUsage = {
workspaceCount: number;
learnerCount: number;
tokensUsedThisMonth: number;
perWorkspace: Array<{
orgId: string;
learnerCount: number;
tokensUsedThisMonth: number;
}>;
};
function startOfCurrentMonth(): Date {
const date = new Date();
date.setDate(1);
date.setHours(0, 0, 0, 0);
return date;
}
export async function getAccountUsage(primaryOrgId: string): Promise<AccountUsage> {
const orgIds = await listAccountOrgIds(primaryOrgId);
if (orgIds.length === 0) {
return { workspaceCount: 0, learnerCount: 0, tokensUsedThisMonth: 0, perWorkspace: [] };
}
const since = startOfCurrentMonth().toISOString();
const [learnerRows, tokenRows] = await Promise.all([
db
.select({
organizationId: schema.organizationmember.organizationId,
c: count()
})
.from(schema.organizationmember)
.where(
and(
inArray(schema.organizationmember.organizationId, orgIds),
eq(schema.organizationmember.roleId, ROLE.STUDENT)
)
)
.groupBy(schema.organizationmember.organizationId),
db
.select({
orgId: schema.aiTokenUsage.orgId,
tokens: sql<number>`COALESCE(SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens})), 0)`
})
.from(schema.aiTokenUsage)
.where(and(inArray(schema.aiTokenUsage.orgId, orgIds), gte(schema.aiTokenUsage.createdAt, since)))
.groupBy(schema.aiTokenUsage.orgId)
]);
const learnerByOrg = new Map<string, number>();
for (const row of learnerRows) {
learnerByOrg.set(row.organizationId, row.c);
}
const tokensByOrg = new Map<string, number>();
for (const row of tokenRows) {
if (row.orgId) tokensByOrg.set(row.orgId, Number(row.tokens) || 0);
}
const perWorkspace = orgIds.map((orgId) => ({
orgId,
learnerCount: learnerByOrg.get(orgId) ?? 0,
tokensUsedThisMonth: tokensByOrg.get(orgId) ?? 0
}));
const learnerCount = perWorkspace.reduce((sum, row) => sum + row.learnerCount, 0);
const tokensUsedThisMonth = perWorkspace.reduce((sum, row) => sum + row.tokensUsedThisMonth, 0);
return {
workspaceCount: orgIds.length,
learnerCount,
tokensUsedThisMonth,
perWorkspace
};
}
+127
View File
@@ -0,0 +1,127 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
countWorkspacesForAccount,
createSecondaryWorkspace,
deleteSecondaryWorkspace,
getAccountPrimary,
getActivePlanForPrimary,
listWorkspacesForAccount
} from '@cio/db/queries/account';
import { canCreateWorkspaces, getWorkspaceAllowance } from '@cio/utils/plans';
import { ROLE } from '@cio/utils/constants';
import type { TOrganization } from '@cio/db/types';
import { checkSiteNameExists } from '@cio/db/queries/organization';
export type AccountWorkspace = TOrganization & {
isPrimary: boolean;
};
async function resolvePrimaryOrThrow(activeOrgId: string): Promise<TOrganization> {
const primary = await getAccountPrimary(activeOrgId);
if (!primary) {
throw new AppError('Active organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
if (primary.id !== activeOrgId) {
throw new AppError(
'Account workspace operations must be performed from the primary workspace',
ErrorCodes.NOT_PRIMARY_WORKSPACE,
403
);
}
return primary;
}
export async function listAccountWorkspaces(activeOrgId: string): Promise<AccountWorkspace[]> {
const primary = await resolvePrimaryOrThrow(activeOrgId);
const rows = await listWorkspacesForAccount(primary.id);
return rows.map((row) => ({
...row,
isPrimary: row.parentOrganizationId === null
}));
}
export async function createWorkspaceForAccount(
activeOrgId: string,
ownerProfileId: string,
input: { name: string; siteName: string }
): Promise<AccountWorkspace> {
const primary = await resolvePrimaryOrThrow(activeOrgId);
const planName = await getActivePlanForPrimary(primary.id);
if (!canCreateWorkspaces(planName)) {
throw new AppError('Multi-workspace requires the Enterprise plan', ErrorCodes.PLAN_UPGRADE_REQUIRED, 402);
}
const allowance = getWorkspaceAllowance(planName);
const current = await countWorkspacesForAccount(primary.id);
if (current >= allowance) {
throw new AppError(
`Workspace limit reached (${allowance} per Enterprise account)`,
ErrorCodes.WORKSPACE_LIMIT_REACHED,
409
);
}
const exists = await checkSiteNameExists(input.siteName);
if (exists) {
throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
}
try {
const created = await createSecondaryWorkspace({
parentOrganizationId: primary.id,
name: input.name,
siteName: input.siteName,
ownerProfileId,
ownerRoleId: ROLE.ADMIN
});
return { ...created, isPrimary: false };
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && (error as { code: unknown }).code === '23505') {
throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
}
throw new AppError(error instanceof Error ? error : new Error('Unknown error'), ErrorCodes.ORG_CREATE_FAILED, 500);
}
}
export async function deleteWorkspaceFromAccount(activeOrgId: string, targetWorkspaceId: string): Promise<void> {
const primary = await resolvePrimaryOrThrow(activeOrgId);
const workspaces = await listWorkspacesForAccount(primary.id);
const target = workspaces.find((row) => row.id === targetWorkspaceId);
if (!target) {
throw new AppError('Workspace not found in account', ErrorCodes.WORKSPACE_NOT_FOUND, 404);
}
if (!target.parentOrganizationId) {
throw new AppError('Cannot delete the primary workspace', ErrorCodes.WORKSPACE_IS_PRIMARY, 400);
}
const result = await deleteSecondaryWorkspace(targetWorkspaceId);
if (!result.deleted) {
if (result.reason === 'IS_PRIMARY') {
throw new AppError('Cannot delete the primary workspace', ErrorCodes.WORKSPACE_IS_PRIMARY, 400);
}
throw new AppError('Workspace not found', ErrorCodes.WORKSPACE_NOT_FOUND, 404);
}
}
export async function getWorkspaceLimits(activeOrgId: string) {
const primary = await resolvePrimaryOrThrow(activeOrgId);
const planName = await getActivePlanForPrimary(primary.id);
const used = await countWorkspacesForAccount(primary.id);
const allowance = getWorkspaceAllowance(planName);
return {
planName,
canCreate: canCreateWorkspaces(planName) && used < allowance,
used,
allowance,
requiresUpgrade: !canCreateWorkspaces(planName)
};
}