feat: migrate auth from supabase to better auth (#596)

* feat: add README for @cio/db package and remove existingAccounts.json

* load session, load profile and organization, add create profile hook

* chore: cache session request

The trick was passing all the cookies to the backend. Previously I was only passing the better-auth.session_token and not better-auth.session_data. Read about the difference here https://www.better-auth.com/docs/concepts/cookies#custom-cookies

* feat: login, logout, onboarding, verify email, reset password.

* feat: complete change email and email verification

* feat: standardise how we make rpc calls and create org endpoints

* fix ui errors

* fix: handle potential undefined customerId and standardize avatar URL property

* refactor: standardize custom domain handling and update telegram chat ID references

* chore: add tailwind prefix to @cio/ui

this fixed the media query issues because  @cio/ui classes were overriding this dashboard tailwind build

* chore: clean up org sidebar

* chore: persist mode state

* feat: implement order breadcrumbs

* chore: upgrade to better auth 1.4

* chore: address common auth flow bugs and setup app for others to setup locally.
This commit is contained in:
Rotimi Best
2025-11-24 01:00:50 +01:00
committed by GitHub
parent 24741644d6
commit a3924100dd
567 changed files with 22364 additions and 13437 deletions
+42 -6
View File
@@ -1,12 +1,18 @@
import 'dotenv/config';
import { Hono } from 'hono';
import { API_SERVER_URL, TRUSTED_ORIGINS } from '@api/constants';
import { Hono } from '@api/utils/hono';
import { accountRouter } from '@api/routes/account';
import { auth } from '@cio/db/auth';
import { cors } from 'hono/cors';
import { courseRouter } from '@api/routes/course/course';
import { courseRouter } from '@api/routes/course';
import { logger } from 'hono/logger';
import { mailRouter } from '@api/routes/mail';
import { onboardingRouter } from '@api/routes/onboarding';
import { organizationRouter } from '@api/routes/organization';
import { prettyJSON } from 'hono/pretty-json';
import { rateLimiterMiddleware } from '@api/middlewares/rate-limiter';
import rateLimiter from '@api/middlewares/rate-limiter';
import { secureHeaders } from 'hono/secure-headers';
// Create Hono app with chaining for RPC support
@@ -18,7 +24,7 @@ export const app = new Hono()
.use(
'*',
cors({
origin: '*',
origin: TRUSTED_ORIGINS,
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
@@ -26,16 +32,46 @@ export const app = new Hono()
credentials: true
})
)
.use('*', rateLimiterMiddleware)
.use('*', rateLimiter)
.use('*', async (c, next) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session) {
c.set('user', null);
c.set('session', null);
await next();
return;
}
c.set('user', session.user);
c.set('session', session.session);
await next();
})
// Routes
.get('/', (c) =>
c.json({
message: '"Welcome to Classroomio.com API - docs are at https://api.classroomio.com/docs"'
message: `"Welcome to Classroomio.com API - docs are at ${API_SERVER_URL}/docs"`
})
)
.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw))
.get('/session', async (c) => {
const session = c.get('session');
const user = c.get('user');
if (!user) return c.body(null, 401);
return c.json({
session,
user
});
})
.route('/onboarding', onboardingRouter)
.route('/account', accountRouter)
.route('/course', courseRouter)
.route('/mail', mailRouter)
.route('/organization', organizationRouter)
// Error handling
.onError((err, c) => {
+4 -2
View File
@@ -9,16 +9,18 @@ const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
OPENAPI_URL: z.string().optional(),
PORT: z.string().optional(),
PRIVATE_SUPABASE_SERVICE_ROLE: z.string(),
PUBLIC_SUPABASE_ANON_KEY: z.string(),
PUBLIC_SUPABASE_URL: z.string(),
PRIVATE_SUPABASE_SERVICE_ROLE: z.string(),
REDIS_URL: z.string().optional(),
SERVER_URL: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
SMTP_PORT: z.string().optional(),
SMTP_SENDER: z.string().optional(),
SMTP_USER: z.string().optional(),
ZOHO_TOKEN: z.string().optional()
ZOHO_TOKEN: z.string().optional(),
TRUSTED_ORIGINS: z.string().optional()
});
export const env = envSchema.parse(process.env);
+6
View File
@@ -2,6 +2,12 @@ import { env } from '@api/config/env';
export * from './rate-limiter';
export const API_PORT = env.PORT ? parseInt(env.PORT) : 3002;
export const API_SERVER_URL = env.SERVER_URL || `http://localhost:${API_PORT}`;
export const TRUSTED_ORIGINS = env.TRUSTED_ORIGINS
? env.TRUSTED_ORIGINS.split(',')
: ['http://localhost:5173', 'http://*.classroomio.*'];
const {
CLOUDFLARE_BUCKET_DOMAIN,
CLOUDFLARE_ACCESS_KEY,
+3 -5
View File
@@ -1,18 +1,16 @@
import 'dotenv/config';
import { API_PORT } from '@api/constants';
import { app } from '@api/app';
import { configureOpenAPI } from '@api/utils/openapi';
import { env } from '@api/config/env';
import { serve } from '@hono/node-server';
import { showRoutes } from 'hono/dev';
// Start server
const port = env.PORT ? parseInt(env.PORT) : 3002;
function startServer() {
console.log('Starting server on port:', port);
console.log('Starting server on port:', API_PORT);
serve({ fetch: app.fetch, port });
serve({ fetch: app.fetch, port: API_PORT });
showRoutes(app, { colorize: true });
}
+2 -27
View File
@@ -1,36 +1,11 @@
import { Context, Next } from 'hono';
import { validateUser } from '@api/utils/auth/validate-user';
export const authMiddleware = async (c: Context, next: Next) => {
try {
const authHeader = c.req.header('authorization');
if (!authHeader) {
return c.json(
{
success: false,
message: 'No authorization header provided'
},
401
);
if (!c.get('user') || !c.get('session')) {
throw new Error('Unauthorized');
}
// Extract the token from "Bearer <token>"
const token = authHeader.split(' ')[1];
if (!token) {
return c.json(
{
success: false,
message: 'Invalid authorization header format'
},
401
);
}
const user = await validateUser(token);
c.set('user', user); // Attach the user to the context
await next();
} catch (error) {
return c.json(
+1 -1
View File
@@ -76,4 +76,4 @@ export const createRateLimiter = (options: RateLimiterOptions = {}): MiddlewareH
};
// Default rate limiter middleware with standard configuration
export const rateLimiterMiddleware = createRateLimiter();
export default createRateLimiter();
+49
View File
@@ -0,0 +1,49 @@
import { getAccountData, updateUser } from '@api/services/account';
import { Hono } from '@api/utils/hono';
import { ZUpdateProfile } from '@cio/utils/validation/account';
import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const accountRouter = new Hono()
.get('/', authMiddleware, async (c) => {
const user = c.get('user');
try {
console.time('accountRouter');
const accountData = await getAccountData(user.id);
console.timeEnd('accountRouter');
return c.json(
{
success: true,
user,
profile: accountData.profile,
organizations: accountData.organizations
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch account data');
}
})
.put('/user', authMiddleware, zValidator('json', ZUpdateProfile), async (c) => {
const user = c.get('user');
try {
const validatedData = c.req.valid('json');
const updatedProfile = await updateUser(user.id, validatedData);
return c.json(
{
success: true,
profile: updatedProfile
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to update profile');
}
});
+1
View File
@@ -0,0 +1 @@
export * from './account';
+26 -36
View File
@@ -1,42 +1,32 @@
import { Hono } from 'hono';
import { ZCourseClone, TCourseClone } from '@api/types/course';
import { zValidator } from '@hono/zod-validator';
import { cloneCourse } from '@api/services/course/clone';
import type { CloneCourseErrorResponse, CloneCourseResponse } from '@api/types/database';
import { Hono } from '@api/utils/hono';
import { ZCourseClone } from '@cio/utils/validation/course';
import { authMiddleware } from '@api/middlewares/auth';
import type { CloneCourseResponse, CloneCourseErrorResponse } from '@api/types/database';
import type { User } from '@supabase/supabase-js';
import { cloneCourse } from '@api/services/course/clone';
import { zValidator } from '@hono/zod-validator';
type Variables = {
user: User;
};
export const cloneRouter = new Hono().post('/', authMiddleware, zValidator('json', ZCourseClone), async (c) => {
try {
const validatedData = c.req.valid('json');
const { id, title, description, slug, organizationId } = validatedData;
export const cloneRouter = new Hono<{ Variables: Variables }>().post(
'/',
authMiddleware,
zValidator('json', ZCourseClone),
async (c) => {
try {
const validatedData = (c.req.valid as (key: 'json') => TCourseClone)('json');
const { id, title, description, slug, organizationId } = validatedData;
const user = c.get('user');
// Get user from context (set by authMiddleware)
const user = c.get('user');
// Clone the course
const newCourse = await cloneCourse(id, title, user.id, description, slug, organizationId);
// Clone the course
const newCourse = await cloneCourse(id, title, user.id, description, slug, organizationId);
const successResponse: CloneCourseResponse = {
success: true,
course: newCourse
};
return c.json(successResponse, 200);
} catch (error) {
console.error('Error cloning course:', error);
const errorResponse: CloneCourseErrorResponse = {
error: 'Failed to clone course',
details: error instanceof Error ? error.message : 'Unknown error'
};
return c.json(errorResponse, 500);
}
const successResponse: CloneCourseResponse = {
success: true,
course: newCourse
};
return c.json(successResponse, 200);
} catch (error) {
console.error('Error cloning course:', error);
const errorResponse: CloneCourseErrorResponse = {
error: 'Failed to clone course',
details: error instanceof Error ? error.message : 'Unknown error'
};
return c.json(errorResponse, 500);
}
);
});
+6 -9
View File
@@ -1,25 +1,22 @@
import { Hono } from 'hono';
import { TCourseClone,TCourseDownloadContent, TCertificateDownload, ZCertificateDownload, ZCourseDownloadContent } from '@api/types/course';
import { ZCertificateDownload, ZCourseDownloadContent } from '@cio/utils/validation/course';
import { Hono } from '@api/utils/hono';
import { cloneRouter } from '@api/routes/course/clone';
import { generateCertificate } from '@api/utils/certificate';
import { generateCoursePdf } from '@api/utils/course';
import { katexRouter } from '@api/routes/course/katex';
import { lessonRouter } from '@api/routes/course/lesson';
import { presignRouter } from '@api/routes/course/presign';
import { cloneRouter } from '@api/routes/course/clone';
import { zValidator } from '@hono/zod-validator';
export const courseRouter = new Hono()
.post('/download/certificate', zValidator('json', ZCertificateDownload), async (c) => {
const validatedData = (c.req.valid as (key: 'json') => TCertificateDownload)('json');
const validatedData = c.req.valid('json');
const result = await generateCertificate(validatedData);
c.header('Content-Type', 'application/pdf');
c.header(
'Content-Disposition',
`attachment; filename="certificate-${validatedData.courseName}.pdf"`
);
c.header('Content-Disposition', `attachment; filename="certificate-${validatedData.courseName}.pdf"`);
return c.body(
new ReadableStream({
@@ -31,7 +28,7 @@ export const courseRouter = new Hono()
);
})
.post('/download/content', zValidator('json', ZCourseDownloadContent), async (c) => {
const validatedData = (c.req.valid as (key: 'json') => TCourseDownloadContent)('json');
const validatedData = c.req.valid('json');
const pdfBuffer = await generateCoursePdf(validatedData);
+1
View File
@@ -0,0 +1 @@
export * from './course';
+2 -2
View File
@@ -1,9 +1,9 @@
import { Context, Hono } from 'hono';
import { Hono } from '@api/utils/hono';
import katex from 'katex';
import { z } from 'zod';
export const katexRouter = new Hono().get('/', async (c: Context) => {
export const katexRouter = new Hono().get('/', async (c) => {
try {
const original = c.req.raw.url;
+15 -19
View File
@@ -1,24 +1,20 @@
import { Hono } from 'hono';
import { TLessonDownloadContent, ZLessonDownloadContent } from '@api/types/course/lesson';
import { Hono } from '@api/utils/hono';
import { ZLessonDownloadContent } from '@cio/utils/validation/course';
import { generateLessonPdf } from '@api/utils/lesson';
import { zValidator } from '@hono/zod-validator';
export const lessonRouter = new Hono().post(
'/download/pdf',
zValidator('json', ZLessonDownloadContent),
async (c) => {
const validatedData = (c.req.valid as (key: 'json') => TLessonDownloadContent)('json');
export const lessonRouter = new Hono().post('/download/pdf', zValidator('json', ZLessonDownloadContent), async (c) => {
const validatedData = c.req.valid('json');
const pdfBuffer = await generateLessonPdf(validatedData);
const pdfBuffer = await generateLessonPdf(validatedData);
c.header('Content-Type', 'application/pdf');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(pdfBuffer);
controller.close();
}
})
);
}
);
c.header('Content-Type', 'application/pdf');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(pdfBuffer);
controller.close();
}
})
);
});
+2 -2
View File
@@ -3,13 +3,13 @@ import {
ZCourseDocumentPresignUrlUpload,
ZCourseDownloadPresignedUrl,
ZCoursePresignUrlUpload
} from '@api/types/course';
} from '@cio/utils/validation/course';
import { describeRoute, validator } from 'hono-openapi';
import { BUCKET_NAME } from '@api/constants/upload';
import { CLOUDFLARE } from '@api/constants';
import type { GetSignedUrlParameters } from '@api/utils/s3';
import { Hono } from 'hono';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { generateFileKey } from '@api/utils/upload';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
-48
View File
@@ -1,48 +0,0 @@
import { sendWithNodemailer, sendWithZoho } from '@api/services/mail';
import { Hono } from 'hono';
import { ZSendEmailValidation } from '@api/types/mail';
import { env } from '@api/config/env';
import { zValidator } from '@hono/zod-validator';
export const mailRouter = new Hono().post(
'/send',
zValidator('json', ZSendEmailValidation),
async (c) => {
const validatedData = c.req.valid('json');
const results = await Promise.all(
validatedData.map(async (emailData) => {
try {
const res = env.ZOHO_TOKEN
? await sendWithZoho(emailData)
: await sendWithNodemailer(emailData);
console.log('Email status:', res);
return res;
} catch (error) {
console.error('Error sending email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
})
);
const hasErrors = results.some((result) => !result.success);
if (hasErrors) {
return c.json(
{
success: false,
error: 'Some emails failed to send',
details: results
},
500
);
}
return c.json({ success: true, details: results });
}
);
+1
View File
@@ -0,0 +1 @@
export * from './mail';
+31
View File
@@ -0,0 +1,31 @@
import { Hono } from '@api/utils/hono';
import { ZDeliverEmail } from '@cio/utils/validation/mail';
import { deliverEmail } from '@cio/email';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const mailRouter = new Hono().post('/send', zValidator('json', ZDeliverEmail), async (c) => {
const validatedData = c.req.valid('json');
try {
// Use service to send emails
const results = await deliverEmail(validatedData);
const hasErrors = results.some((result) => !result.success);
if (hasErrors) {
return c.json(
{
success: false,
error: 'Some emails failed to send',
code: 'EMAIL_SEND_PARTIAL_FAILURE',
details: results
},
500
);
}
return c.json({ success: true, data: results }, 200);
} catch (error) {
return handleError(c, error, 'Failed to process email send request', 'EMAIL_SEND_FAILED');
}
});
+1
View File
@@ -0,0 +1 @@
export * from './onboarding';
@@ -0,0 +1,44 @@
import { ZOnboardingStep1, ZOnboardingStep2 } from '@cio/utils/validation/onboarding';
import { completeOnboarding, createOrganizationWithOwner, updateUserOnboarding } from '@api/services/onboarding';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const onboardingRouter = new Hono()
.post('/step1', authMiddleware, zValidator('json', ZOnboardingStep1), async (c) => {
try {
const user = c.get('user');
const { fullname, orgName, siteName } = c.req.valid('json');
const result = await createOrganizationWithOwner(user.id, { fullname, orgName, siteName });
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create organization');
}
})
.post('/step2', authMiddleware, zValidator('json', ZOnboardingStep2), async (c) => {
try {
const user = c.get('user');
const { goal, source, fullname } = c.req.valid('json');
const result = await updateUserOnboarding(user.id, { goal, source, fullname });
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update onboarding data');
}
})
.post('/complete', authMiddleware, async (c) => {
try {
const user = c.get('user');
await completeOnboarding(user);
return c.json({ success: true }, 200);
} catch (error) {
return handleError(c, error, 'Failed to complete onboarding');
}
});
@@ -0,0 +1 @@
export * from './organization';
@@ -0,0 +1,172 @@
import {
ZCancelOrgPlan,
ZCreateOrgPlan,
ZGetCoursesBySiteName,
ZGetOrganizationAudience,
ZGetOrganizationTeam,
ZGetOrganizations,
ZUpdateOrgPlan
} from '@cio/utils/validation/organization';
import {
cancelOrgPlan,
createOrgPlan,
getCoursesByOrgSiteName,
getOrgAudience,
getOrgTeam,
getOrganizationsWithFilters,
updateOrgPlan
} from '@api/services/organization';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
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)
*/
.get('/', 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) {
return handleError(c, error, 'Failed to fetch organizations');
}
})
/**
* GET /organization/:orgId/team
* Gets organization team members (non-students)
* Requires authentication
*/
.get('/:orgId/team', authMiddleware, zValidator('param', ZGetOrganizationTeam), async (c) => {
try {
const { orgId } = c.req.valid('param');
const team = await getOrgTeam(orgId);
return c.json(
{
success: true,
data: team
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch organization team');
}
})
/**
* GET /organization/:orgId/audience
* Gets organization audience (students)
* Requires authentication
*/
.get('/:orgId/audience', authMiddleware, zValidator('param', ZGetOrganizationAudience), async (c) => {
try {
const { orgId } = c.req.valid('param');
const audience = await getOrgAudience(orgId);
return c.json(
{
success: true,
data: audience
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch organization audience');
}
})
/**
* GET /organization/courses
* Gets courses by organization siteName
* Query params: siteName (string)
*/
.get('/courses', 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');
}
})
/**
* POST /organization/plan
* Creates a new organization plan
* Requires authentication
*/
.post('/plan', authMiddleware, 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
*/
.put('/plan', authMiddleware, 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
*/
.post('/plan/cancel', authMiddleware, 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');
}
});
+1 -3
View File
@@ -1,9 +1,7 @@
import { app } from './app';
import { hc } from 'hono/client';
import type { InferRequestType, InferResponseType } from 'hono/client';
export type { InferRequestType, InferResponseType } from 'hono/client';
export type Client = ReturnType<typeof hc<typeof app>>;
export const hcWithType = (...args: Parameters<typeof hc>): Client => hc<typeof app>(...args);
export type { InferRequestType, InferResponseType };
+59
View File
@@ -0,0 +1,59 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { getProfileById, updateProfile } from '@cio/db/queries/auth';
import type { TProfile } from '@cio/db/types';
import { getOrganizationByProfileId } from '@cio/db/queries/organization';
/**
* Fetches account data including profile and organizations for a user
* @param userId - The user ID to fetch account data for
* @returns Account data with profile and organizations
*/
export async function getAccountData(userId: string) {
const [profile, organizations] = await Promise.all([getProfileById(userId), getOrganizationByProfileId(userId)]);
if (!profile) {
throw new AppError(`Account not found for user ID: ${userId}`, ErrorCodes.ACCOUNT_NOT_FOUND, 404);
}
return {
profile,
organizations
};
}
/**
* 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
);
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './account';
export * from './onboarding';
export * from './mail';
+4 -10
View File
@@ -1,13 +1,9 @@
import { extractNameAndEmail, withEmailTemplate } from '@api/utils/mail';
import { nodemailerTransporter, zohoClient } from '@api/utils/email';
import { extractNameAndEmail, nodemailerTransporter, withEmailTemplate, zohoClient } from '@api/utils/mail';
import { Hono } from 'hono';
import type { TSendEmailValidation } from '@api/types/mail';
import type { TEmailData } from '@cio/utils/validation/mail';
import type { Transporter } from 'nodemailer';
import { env } from '@api/config/env';
export const mailRouter = new Hono();
interface EmailResponse {
success: boolean;
error?: string;
@@ -20,9 +16,7 @@ nodemailerTransporter().then((t: Transporter | null) => {
transporter = t;
});
export async function sendWithNodemailer(
emailData: TSendEmailValidation[0]
): Promise<EmailResponse> {
export async function sendWithNodemailer(emailData: TEmailData): Promise<EmailResponse> {
const { from, to, subject, content, replyTo } = emailData;
if (!transporter) {
@@ -54,7 +48,7 @@ export async function sendWithNodemailer(
}
}
export async function sendWithZoho(emailData: TSendEmailValidation[0]): Promise<EmailResponse> {
export async function sendWithZoho(emailData: TEmailData): Promise<EmailResponse> {
const { from, to, subject, content } = emailData;
const fromData = extractNameAndEmail(from || '');
+99
View File
@@ -0,0 +1,99 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import type { TProfile, TUser } from '@cio/db/types';
import {
checkSiteNameExists,
createOrganization,
createOrganizationMember,
getOrganizationByProfileId
} from '@cio/db/queries';
import { getProfileById, updateProfile } from '@cio/db/queries/auth';
import { ROLE } from '@cio/utils/constants';
import { db } from '@cio/db/drizzle';
import { sendEmail } from '@cio/email';
export async function createOrganizationWithOwner(
profileId: string,
input: {
fullname?: string;
orgName: string;
siteName: string;
}
) {
// Business Logic: Check sitename availability
const exists = await checkSiteNameExists(input.siteName);
if (exists) {
console.error('Site name already exists:', input.siteName);
throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
}
// Business Logic: Create org and member in a transaction
try {
const result = await db.transaction(async () => {
const organization = await createOrganization({
name: input.orgName,
siteName: input.siteName
});
const member = await createOrganizationMember({
organizationId: organization.id,
profileId,
roleId: ROLE.ADMIN,
verified: true
});
return { organization, member };
});
// Fetch updated organizations list
const organizations = await getOrganizationByProfileId(profileId);
return {
organization: result.organization,
member: result.member,
organizations
};
} catch (error) {
console.error('Error creating organization:', error);
// Handle database constraint violations
if (error.code === '23505') {
throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
}
throw new AppError(error, ErrorCodes.ORG_CREATE_FAILED, 500);
}
}
export async function updateUserOnboarding(userId: string, data: Partial<TProfile>) {
try {
const updatedProfile = await updateProfile(userId, data);
if (!updatedProfile) {
throw new AppError('Failed to update profile - profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404);
}
return updatedProfile;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(error, ErrorCodes.PROFILE_UPDATE_FAILED, 500);
}
}
export async function completeOnboarding(user: Partial<TUser>) {
try {
const profile = await getProfileById(user.id);
if (!profile) {
throw new AppError('Profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404);
}
await sendEmail('welcome', {
to: user.email,
fields: {
name: profile.fullname
}
});
} catch (error) {
throw new AppError(error, ErrorCodes.INTERNAL_ERROR, 500);
}
}
+165
View File
@@ -0,0 +1,165 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import type { TNewOrganizationPlan, TOrganizationPlan, TPlan } from '@db/types';
import {
cancelOrganizationPlan,
createOrganizationPlan,
getOrganizationAudience,
getOrganizationTeam,
getOrganizations,
updateOrganizationPlan
} from '@cio/db/queries/organization';
import { getCoursesBySiteName } from '@cio/db/queries/course';
/**
* Gets organizations with optional filters
* @param filters - Filter options (siteName, customDomain, isCustomDomainVerified)
* @returns Array of organizations with plans
*/
export async function getOrganizationsWithFilters(filters?: {
siteName?: string;
customDomain?: string;
isCustomDomainVerified?: boolean;
}) {
try {
const organizations = await getOrganizations(filters);
return organizations;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch organizations',
ErrorCodes.ORGANIZATION_NOT_FOUND,
500
);
}
}
/**
* Gets organization team members (non-students)
* @param orgId - The organization ID
* @returns Array of team members
*/
export async function getOrgTeam(orgId: string) {
try {
const team = await getOrganizationTeam(orgId);
return team;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch organization team',
ErrorCodes.ORG_TEAM_FETCH_FAILED,
500
);
}
}
/**
* Gets organization audience (students)
* @param orgId - The organization ID
* @returns Array of student profiles
*/
export async function getOrgAudience(orgId: string) {
try {
const audience = await getOrganizationAudience(orgId);
return audience;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch organization audience',
ErrorCodes.ORG_AUDIENCE_FETCH_FAILED,
500
);
}
}
/**
* Gets courses by organization siteName
* @param siteName - The organization site name
* @returns Array of courses
*/
export async function getCoursesByOrgSiteName(siteName: string) {
try {
const courses = await getCoursesBySiteName(siteName);
return courses;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch courses',
ErrorCodes.COURSES_FETCH_FAILED,
500
);
}
}
/**
* Creates a new organization plan
* @param data - Organization plan creation data
* @returns Created organization plan
*/
export async function createOrgPlan(data: TNewOrganizationPlan) {
try {
const plan = await createOrganizationPlan({
orgId: data.orgId,
planName: data.planName,
subscriptionId: data.subscriptionId,
triggeredBy: data.triggeredBy,
payload: data.payload,
isActive: true,
provider: 'polar'
});
return plan;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to create organization plan',
ErrorCodes.ORG_PLAN_CREATE_FAILED,
500
);
}
}
/**
* Updates an organization plan
* @param subscriptionId - Subscription ID
* @param payload - Payload data to update
* @returns Updated organization plan
*/
export async function updateOrgPlan(subscriptionId: string, payload: TOrganizationPlan['payload']) {
try {
const plan = await updateOrganizationPlan(subscriptionId, payload);
if (!plan) {
throw new AppError('Organization plan not found', ErrorCodes.ORG_PLAN_NOT_FOUND, 404);
}
return plan;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update organization plan',
ErrorCodes.ORG_PLAN_UPDATE_FAILED,
500
);
}
}
/**
* Cancels an organization plan
* @param subscriptionId - Subscription ID
* @param payload - Payload data to update
* @returns Updated organization plan
*/
export async function cancelOrgPlan(subscriptionId: string, payload: TOrganizationPlan['payload']) {
try {
const plan = await cancelOrganizationPlan(subscriptionId, payload);
if (!plan) {
throw new AppError('Organization plan not found', ErrorCodes.ORG_PLAN_NOT_FOUND, 404);
}
return plan;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to cancel organization plan',
ErrorCodes.ORG_PLAN_CANCEL_FAILED,
500
);
}
}
+8
View File
@@ -0,0 +1,8 @@
import { auth } from '@cio/db/auth';
export type AuthSession = {
Variables: {
user: typeof auth.$Infer.Session.user | null;
session: typeof auth.$Infer.Session.session | null;
};
};
-60
View File
@@ -1,60 +0,0 @@
import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '@api/constants/upload';
import { z } from 'zod';
export * from './lesson';
export const ZCourseDownloadContent = z.object({
courseTitle: z.string(),
orgName: z.string(),
orgTheme: z.string(),
lessons: z.array(
z.object({
lessonTitle: z.string(),
lessonNumber: z.string(),
lessonNote: z.string(),
slideUrl: z.string().optional(),
video: z.array(z.string()).optional()
})
)
});
export type TCourseDownloadContent = z.infer<typeof ZCourseDownloadContent>;
export const ZCoursePresignUrlUpload = z.object({
fileName: z.string().min(1, 'File name is required'),
fileType: z.enum(ALLOWED_CONTENT_TYPES, {
error: () => ({
message: `Invalid content type. Allowed types: ${ALLOWED_CONTENT_TYPES.join(', ')}`
})
})
});
export type TCoursePresignUrlUpload = z.infer<typeof ZCoursePresignUrlUpload>;
export const ZCourseDocumentPresignUrlUpload = z.object({
fileName: z.string().min(1, 'File name is required'),
fileType: z.enum(ALLOWED_DOCUMENT_TYPES, {
error: () => ({
message: `Invalid content type. Allowed types: ${ALLOWED_DOCUMENT_TYPES.join(', ')}`
})
})
});
export type TCourseDocumentPresignUrlUpload = z.infer<typeof ZCourseDocumentPresignUrlUpload>;
export const ZCourseDownloadPresignedUrl = z.object({
keys: z.array(z.string()).min(1, 'File names array is required')
});
export type TCourseDownloadPresignedUrl = z.infer<typeof ZCourseDownloadPresignedUrl>;
export const ZCourseClone = z.object({
id: z.string().min(1, 'Course ID is required'),
title: z.string().min(1, 'Course title is required'),
description: z.string().optional(),
slug: z.string().min(1, 'Course slug is required'),
organizationId: z.string().min(1, 'Organization ID is required')
});
export type TCourseClone = z.infer<typeof ZCourseClone>;
-25
View File
@@ -1,25 +0,0 @@
import { z } from 'zod';
export const ZLessonDownloadContent = z.object({
title: z.string(),
number: z.string(),
orgName: z.string(),
note: z.string(),
slideUrl: z.string().optional(),
video: z.array(z.string()).optional(),
courseTitle: z.string()
});
export type TLessonDownloadContent = z.infer<typeof ZLessonDownloadContent>;
export const ZCertificateDownload = z.object({
theme: z.string().min(1),
studentName: z.string().min(1),
courseName: z.string().min(1),
courseDescription: z.string().min(1),
orgName: z.string().min(1),
orgLogoUrl: z.string().url().optional(),
facilitator: z.string().optional()
});
export type TCertificateDownload = z.infer<typeof ZCertificateDownload>;
-3
View File
@@ -1,5 +1,2 @@
// Re-export all types
export * from './course';
export * from './database';
export * from './mail';
-13
View File
@@ -1,13 +0,0 @@
import { z } from 'zod';
export const ZSendEmailValidation = z.array(
z.object({
from: z.string().optional(),
to: z.string().email(),
subject: z.string().min(1),
content: z.string().min(1),
replyTo: z.string().email().optional()
})
);
export type TSendEmailValidation = z.infer<typeof ZSendEmailValidation>;
+1 -11
View File
@@ -1,16 +1,6 @@
import type { TCertificateDownload } from '@api/types/course/lesson';
import type { TCertificateDownload } from '@cio/utils/validation/course';
import { getCloudflarePdfBuffer } from '@api/utils/cloudflare';
export interface CertificateData {
theme: string;
studentName: string;
courseName: string;
courseDescription: string;
orgLogoUrl: string;
orgName: string;
facilitator?: string;
}
export interface CertificateGenerationResult {
success: boolean;
buffer?: Buffer;
+3 -12
View File
@@ -1,4 +1,4 @@
import type { TCourseDownloadContent } from '@api/types/course';
import type { TCourseDownloadContent } from '@cio/utils/validation/course';
import { getCloudflarePdfBuffer } from '@api/utils/cloudflare';
import { marked } from 'marked';
@@ -138,12 +138,7 @@ function getLessonBody(
`;
}
function getCourseBody({
courseTitle,
orgName,
orgTheme,
lessons
}: TCourseDownloadContent): string {
function getCourseBody({ courseTitle, orgName, orgTheme, lessons }: TCourseDownloadContent): string {
return `
<header class="flex justify-center items-center flex-col border-[${orgTheme ? orgTheme : '#0030FF'}] border-[40px] mx-3 h-[100vh] m-0">
<h1 class="font-bold text-8xl text-black m-0 mx-96">${courseTitle}</h1>
@@ -155,11 +150,7 @@ function getCourseBody({
`;
}
async function generateSinglePdfFromHtml(
htmlContent: string,
courseTitle: string,
orgTheme: string
): Promise<Buffer> {
async function generateSinglePdfFromHtml(htmlContent: string, courseTitle: string, orgTheme: string): Promise<Buffer> {
return await getCloudflarePdfBuffer(htmlContent);
}
-35
View File
@@ -1,35 +0,0 @@
import { SendMailClient } from 'zeptomail';
import type { Transporter } from 'nodemailer';
import { env } from '@api/config/env';
import nodemailer from 'nodemailer';
export const zohoClient = new SendMailClient({
url: 'api.zeptomail.eu/',
token: env.ZOHO_TOKEN!
});
export const nodemailerTransporter = async (): Promise<Transporter | null> => {
if (!env.SMTP_HOST || !env.SMTP_PORT || !env.SMTP_USER || !env.SMTP_PASSWORD) {
console.error('SMTP configuration missing');
return null;
}
try {
const transporter = nodemailer.createTransport({
host: env.SMTP_HOST,
port: parseInt(env.SMTP_PORT || '465', 10),
secure: true,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASSWORD
}
});
await transporter.verify();
return transporter;
} catch (error) {
console.error('Transporter error:', error);
return null;
}
};
+108
View File
@@ -0,0 +1,108 @@
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import type { Context } from 'hono';
/**
* Standard application error with code for easy identification
*/
export class AppError extends Error {
constructor(
error: string | Error,
public code: string,
public statusCode: ContentfulStatusCode = 500,
public field?: string
) {
super(error instanceof Error ? error.message : error);
this.name = 'AppError';
}
}
/**
* Standard error response format
*/
export interface ErrorResponse {
success: false;
error: string;
code: string;
field?: string;
}
// Error codes constants for consistency
export const ErrorCodes = {
// Organization errors
SITENAME_EXISTS: 'SITENAME_EXISTS',
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',
ORG_NOT_FOUND: 'ORG_NOT_FOUND',
ORGANIZATION_NOT_FOUND: 'ORGANIZATION_NOT_FOUND',
ORG_TEAM_FETCH_FAILED: 'ORG_TEAM_FETCH_FAILED',
ORG_AUDIENCE_FETCH_FAILED: 'ORG_AUDIENCE_FETCH_FAILED',
COURSES_FETCH_FAILED: 'COURSES_FETCH_FAILED',
ORG_PLAN_CREATE_FAILED: 'ORG_PLAN_CREATE_FAILED',
ORG_PLAN_UPDATE_FAILED: 'ORG_PLAN_UPDATE_FAILED',
ORG_PLAN_CANCEL_FAILED: 'ORG_PLAN_CANCEL_FAILED',
ORG_PLAN_NOT_FOUND: 'ORG_PLAN_NOT_FOUND',
// Profile errors
PROFILE_UPDATE_FAILED: 'PROFILE_UPDATE_FAILED',
PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND',
// Account errors
ACCOUNT_NOT_FOUND: 'ACCOUNT_NOT_FOUND',
// Auth errors
UNAUTHORIZED: 'UNAUTHORIZED',
FORBIDDEN: 'FORBIDDEN',
// Generic
VALIDATION_ERROR: 'VALIDATION_ERROR',
INTERNAL_ERROR: 'INTERNAL_ERROR'
} as const;
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
/**
* Handles errors consistently across all routes
* @param c - Hono context
* @param error - The error to handle
* @param fallbackMessage - Default error message if error is not an AppError
* @param fallbackCode - Default error code if error is not an AppError
* @returns JSON error response with appropriate status code
*
* @example
* ```typescript
* try {
* const data = await someService();
* return c.json({ success: true, data });
* } catch (error) {
* return handleError(c, error, 'Failed to process request');
* }
* ```
*/
export const handleError = (
c: Context,
error: unknown,
fallbackMessage: string = 'An unexpected error occurred',
fallbackCode: string = ErrorCodes.INTERNAL_ERROR
) => {
console.error('Error in route:', error);
if (error instanceof AppError) {
return c.json<ErrorResponse>(
{
success: false,
error: error.message,
code: error.code,
field: error.field
},
error.statusCode
);
}
return c.json<ErrorResponse>(
{
success: false,
error: fallbackMessage,
code: fallbackCode
},
500
);
};
+4
View File
@@ -0,0 +1,4 @@
import { Hono as HonoBase } from 'hono';
import { AuthSession } from '@api/types/auth';
export const Hono = HonoBase<AuthSession>;
+3 -14
View File
@@ -1,4 +1,4 @@
import type { TLessonDownloadContent } from '@api/types/course/lesson';
import type { TLessonDownloadContent } from '@cio/utils/validation/course';
import { marked } from 'marked';
function getHtmlTemplate(body: string): string {
@@ -74,15 +74,7 @@ function getHtmlTemplate(body: string): string {
`;
}
function getLessonBody({
title,
number,
orgName,
note,
slideUrl,
video,
courseTitle
}: TLessonDownloadContent): string {
function getLessonBody({ title, number, orgName, note, slideUrl, video, courseTitle }: TLessonDownloadContent): string {
const noteHtml = marked.parse(note);
const showExtraResources = slideUrl || (video && video.length > 0);
return `
@@ -146,10 +138,7 @@ function getLessonBody({
`;
}
async function generateSinglePdfFromHtml(
htmlContent: string,
courseTitle: string
): Promise<Buffer> {
async function generateSinglePdfFromHtml(htmlContent: string, courseTitle: string): Promise<Buffer> {
throw 'Download disabled';
}
+36
View File
@@ -1,3 +1,39 @@
import { SendMailClient } from 'zeptomail';
import type { Transporter } from 'nodemailer';
import { env } from '@api/config/env';
import nodemailer from 'nodemailer';
export const zohoClient = new SendMailClient({
url: 'api.zeptomail.eu/',
token: env.ZOHO_TOKEN!
});
export const nodemailerTransporter = async (): Promise<Transporter | null> => {
if (!env.SMTP_HOST || !env.SMTP_PORT || !env.SMTP_USER || !env.SMTP_PASSWORD) {
console.error('SMTP configuration missing');
return null;
}
try {
const transporter = nodemailer.createTransport({
host: env.SMTP_HOST,
port: parseInt(env.SMTP_PORT || '465', 10),
secure: true,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASSWORD
}
});
await transporter.verify();
return transporter;
} catch (error) {
console.error('Transporter error:', error);
return null;
}
};
interface FromData {
name: string;
email: string;
+2 -1
View File
@@ -1,8 +1,9 @@
import { Hono } from 'hono';
import { Scalar } from '@scalar/hono-api-reference';
import { env } from '@api/config/env';
import { AuthSession } from '@api/types/auth';
export function configureOpenAPI(app: Hono) {
export function configureOpenAPI(app: Hono<AuthSession>) {
if (env.OPENAPI_URL) {
app.get('/docs', Scalar({ url: env.OPENAPI_URL, theme: 'none' }));
}