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:
+42
-6
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -76,4 +76,4 @@ export const createRateLimiter = (options: RateLimiterOptions = {}): MiddlewareH
|
||||
};
|
||||
|
||||
// Default rate limiter middleware with standard configuration
|
||||
export const rateLimiterMiddleware = createRateLimiter();
|
||||
export default createRateLimiter();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './account';
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './course';
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from './mail';
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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,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 };
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './account';
|
||||
export * from './onboarding';
|
||||
export * from './mail';
|
||||
@@ -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 || '');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -1,5 +1,2 @@
|
||||
// Re-export all types
|
||||
export * from './course';
|
||||
export * from './database';
|
||||
export * from './mail';
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Hono as HonoBase } from 'hono';
|
||||
import { AuthSession } from '@api/types/auth';
|
||||
|
||||
export const Hono = HonoBase<AuthSession>;
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user