feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './profile';
|
||||
export * from './workspaces';
|
||||
export * from './quota';
|
||||
export * from './grace';
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user