diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore index 28cde21f..80647510 100644 --- a/apps/backend/.gitignore +++ b/apps/backend/.gitignore @@ -1,3 +1,4 @@ node_modules .env -.DS_Store \ No newline at end of file +.DS_Store +dist \ No newline at end of file diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index aa746312..dff48ecc 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -12,6 +12,7 @@ ARG SMTP_HOST ARG SMTP_USER ARG SMTP_PASSWORD ARG SMTP_PORT +ARG SMTP_SENDER RUN mkdir /app WORKDIR /app @@ -63,6 +64,8 @@ ENV NODE_ENV=production \ SMTP_PORT=$SMTP_PORT \ PORT=3002 +RUN pnpm build + EXPOSE 3002 -CMD ["node", "server.js"] +CMD ["pnpm", "start"] diff --git a/apps/backend/package.json b/apps/backend/package.json index 147db941..19d3e9c8 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -1,44 +1,64 @@ { "name": "backend", "version": "0.0.1", - "description": "Express Server for long processing", - "main": "server.js", + "description": "ClassroomIO Backend for long processes", + "main": "dist/index.js", + "type": "module", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "start": "node server.js", - "dev": "nodemon server.js" + "build": "tsc && tsc-alias", + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "test": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "eslint . --ext .ts", + "format": "prettier --write \"src/**/*.ts\"" }, "repository": { "type": "git", - "url": "git+https://github.com/rotimi-best/classroomio.git" + "url": "git+https://github.com/classroomio/classroomio.git" }, "author": "", "license": "ISC", "bugs": { - "url": "https://github.com/rotimi-best/classroomio/issues" + "url": "https://github.com/classroomio/classroomio/issues" }, - "homepage": "https://github.com/rotimi-best/classroomio#readme", + "homepage": "https://github.com/classroomio/classroomio#readme", "dependencies": { "@aws-sdk/client-s3": "^3.388.0", - "@aws-sdk/lib-storage": "^3.535.0", + "@aws-sdk/s3-request-presigner": "^3.675.0", + "@hono/node-server": "^1.8.2", "@sentry/node": "^7.110.1", "@sentry/profiling-node": "^7.110.1", "@supabase/supabase-js": "^2.32.0", - "@types/nodemailer": "^6.4.11", "axios": "^1.4.0", - "body-parser": "^1.20.2", - "bull": "^4.11.3", - "cors": "^2.8.5", "dotenv": "^16.3.1", - "express": "^4.17.1", - "express-rate-limit": "^6.9.0", - "form-data": "^4.0.0", + "hono": "^4.0.5", + "hono-rate-limiter": "^0.4.2", "katex": "^0.16.10", "marked": "^7.0.3", - "multer": "^1.4.3", + "nanoid": "^5.1.5", "nodemailer": "^6.9.5", - "nodemon": "^3.0.1", + "puppeteer": "^24.10.0", "zeptomail": "^6.0.0", "zod": "^3.21.4" + }, + "devDependencies": { + "@types/katex": "^0.16.7", + "@types/node": "^20.11.19", + "@types/nodemailer": "^6.4.17", + "@types/puppeteer": "^7.0.4", + "@typescript-eslint/eslint-plugin": "^7.0.1", + "@typescript-eslint/parser": "^7.0.1", + "@vitest/coverage-v8": "^1.2.2", + "eslint": "^8.56.0", + "prettier": "^3.2.5", + "ts-node": "^10.9.2", + "tsc-alias": "^1.8.16", + "tsx": "^4.7.1", + "typescript": "^5.5.2", + "vitest": "^1.2.2" + }, + "_moduleAliases": { + "$src": "src" } } diff --git a/apps/backend/server.js b/apps/backend/server.js deleted file mode 100644 index 1cbce1ad..00000000 --- a/apps/backend/server.js +++ /dev/null @@ -1,75 +0,0 @@ -require('dotenv').config(); - -const express = require('express'); -const rateLimit = require('express-rate-limit'); -const cors = require('cors'); -const bodyParser = require('body-parser'); -// const sentry = require('@sentry/node'); -// const { ProfilingIntegration } = require('@sentry/profiling-node'); - -const uploadVideo = require('./src/routes/uploadVideo'); -const downloadCertificate = require('./src/routes/downloadCertificate'); -const downloadLesson = require('./src/routes/downloadLesson'); -const downloadCourse = require('./src/routes/downloadCourse'); -const katex = require('./src/routes/katex'); -const sendEmail = require('./src/routes/sendEmail'); - -// sentry.init({ -// dsn: process.env.SENTRY_DNS || '', -// integrations: [new ProfilingIntegration()], -// tracesSampleRate: 1.0, -// profilesSampleRate: 1.0 -// }); - -// Express server -const app = express(); -const port = process.env.PORT || 3002; - -app.use(express.json()); -app.use(bodyParser.urlencoded({ extended: true })); -app.use(cors()); - -// Rate limiting middleware -const limiter = rateLimit({ - windowMs: 1000, - max: 10 -}); - -app.use(limiter); - -// Integrate Sentry middleware -// app.use(sentry.Handlers.requestHandler()); -// app.use(sentry.Handlers.errorHandler()); - -// Register routes -app.use('/downloadCertificate', downloadCertificate); -app.use('/downloadLesson', downloadLesson); -app.use('/downloadCourse', downloadCourse); -app.use('/uploadVideo', uploadVideo); -app.use('/katex', katex); -app.use('/sendEmail', sendEmail); - -app.get('/', (req, res) => { - res.send('Welcome to ClassroomIO'); -}); - -// Start server -app.listen(port, () => { - console.log(`ClassroomIO Backend listening on port ${port}!`); -}); - -// Handle uncaught exceptions -process.on('uncaughtException', (err) => { - console.error('Uncaught error', err); -}); - -// Error-handling middleware -app.use((error, req, res, next) => { - console.error('Exception error caught:', error); - - // Capture and send the error to Sentry - // sentry.captureException(error); - - // Respond with a 500 Internal Server Error - res.status(500).json({ error: 'Internal Server Error' }); -}); diff --git a/apps/backend/src/config/env.ts b/apps/backend/src/config/env.ts new file mode 100644 index 00000000..0e18d88c --- /dev/null +++ b/apps/backend/src/config/env.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +const envSchema = z.object({ + PORT: z.string().optional(), + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + CLOUDFLARE_BUCKET_DOMAIN: z.string().optional(), + CLOUDFLARE_ACCESS_KEY: z.string().optional(), + CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(), + CLOUDFLARE_ACCOUNT_ID: z.string().optional(), + PUBLIC_SUPABASE_URL: z.string(), + PUBLIC_SUPABASE_ANON_KEY: z.string(), + ZOHO_TOKEN: z.string().optional(), + SMTP_HOST: z.string().optional(), + SMTP_PORT: z.string().optional(), + SMTP_USER: z.string().optional(), + SMTP_PASSWORD: z.string().optional(), + SMTP_SENDER: z.string().optional() +}); + +export const env = envSchema.parse(process.env); diff --git a/apps/backend/src/constants/index.ts b/apps/backend/src/constants/index.ts new file mode 100644 index 00000000..9db00bc9 --- /dev/null +++ b/apps/backend/src/constants/index.ts @@ -0,0 +1,25 @@ +import { env } from '$src/config/env'; + +const { + CLOUDFLARE_BUCKET_DOMAIN, + CLOUDFLARE_ACCESS_KEY, + CLOUDFLARE_SECRET_ACCESS_KEY, + CLOUDFLARE_ACCOUNT_ID +} = env; + +export const CLOUDFLARE = { + CONFIGS: { + BUCKET_DOMAIN: CLOUDFLARE_BUCKET_DOMAIN!, + ACCESS_KEY: CLOUDFLARE_ACCESS_KEY!, + SECRET_ACCESS_KEY: CLOUDFLARE_SECRET_ACCESS_KEY!, + ACCOUNT_ID: CLOUDFLARE_ACCOUNT_ID! + }, + R2: { + BUCKET: 'videos', + BUCKET_DOMAIN: CLOUDFLARE_ACCOUNT_ID + ? `https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com` + : CLOUDFLARE_BUCKET_DOMAIN!, + PRESIGN_EXPIRATION_TIME: 60 * 60, // 1 hour + DOWNLOAD_EXPIRATION_TIME: 60 * 60 * 3 // 3 hours + } +} as const; diff --git a/apps/backend/src/constants/upload.ts b/apps/backend/src/constants/upload.ts new file mode 100644 index 00000000..c629ebb3 --- /dev/null +++ b/apps/backend/src/constants/upload.ts @@ -0,0 +1,8 @@ +export const ALLOWED_CONTENT_TYPES = [ + 'video/mp4', + 'video/quicktime', + 'video/x-msvideo', + 'video/x-matroska' +] as const; + +export const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts new file mode 100644 index 00000000..e30e06c6 --- /dev/null +++ b/apps/backend/src/index.ts @@ -0,0 +1,66 @@ +import 'dotenv/config'; + +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { courseRouter } from '$src/routes/course/course'; +import { env } from '$src/config/env'; +import { logger } from 'hono/logger'; +import { mailRouter } from '$src/routes/mail'; +import { prettyJSON } from 'hono/pretty-json'; +import { rateLimiter } from 'hono-rate-limiter'; +import { secureHeaders } from 'hono/secure-headers'; +import { serve } from '@hono/node-server'; +import { showRoutes } from 'hono/dev'; + +// Create Hono app +const app = new Hono(); + +// Middleware +app.use('*', logger()); +app.use('*', prettyJSON()); +app.use('*', secureHeaders()); +app.use( + '*', + cors({ + origin: '*', + allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization'], + exposeHeaders: ['Content-Length', 'X-Kuma-Revision'], + maxAge: 600, + credentials: true + }) +); + +app.use( + rateLimiter({ + windowMs: 15 * 60 * 1000, // 15 minutes + limit: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes). + standardHeaders: 'draft-6', // draft-6: `RateLimit-*` headers; draft-7: combined `RateLimit` header + keyGenerator: (c) => c.req.header('Authorization')?.split(' ')[1] ?? 'unknown' // Method to generate custom identifiers for clients. + }) +); + +// Routes +app.get('/', (c) => c.json({ message: 'Welcome to ClassroomIO' })); + +app.route('/course', courseRouter); +app.route('/mail', mailRouter); + +// Error handling +app.onError((err, c) => { + console.error('Error:', err); + return c.json({ error: 'Internal Server Error' }, 500); +}); + +// Start server +const port = env.PORT ? parseInt(env.PORT) : 3002; + +function startServer() { + console.log('Starting server...'); + + serve({ fetch: app.fetch, port }); + + showRoutes(app, { colorize: true }); +} + +startServer(); diff --git a/apps/backend/src/middlewares/auth.ts b/apps/backend/src/middlewares/auth.ts new file mode 100644 index 00000000..4c6d9aa2 --- /dev/null +++ b/apps/backend/src/middlewares/auth.ts @@ -0,0 +1,44 @@ +import { Context, Next } from 'hono'; + +import { validateUser } from '$src/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 + ); + } + + // Extract the token from "Bearer " + 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( + { + success: false, + message: 'Unauthorized' + }, + 401 + ); + } +}; diff --git a/apps/backend/src/routes/course/course.ts b/apps/backend/src/routes/course/course.ts new file mode 100644 index 00000000..e078d9e3 --- /dev/null +++ b/apps/backend/src/routes/course/course.ts @@ -0,0 +1,91 @@ +import { Context, Hono } from 'hono'; +import { ZCertificateDownload, ZCourseDownloadContent } from '$src/types/course'; + +import { generateCertificate } from '$src/utils/certificate'; +import { generateCoursePdf } from '$src/utils/course'; +import { katexRouter } from '$src/routes/course/katex'; +import { lessonRouter } from '$src/routes/course/lesson'; +import { presignRouter } from '$src/routes/course/presign'; +import { z } from 'zod'; + +export const courseRouter = new Hono(); + +courseRouter.route('/katex', katexRouter); +courseRouter.route('/lesson', lessonRouter); +courseRouter.route('/presign', presignRouter); + +courseRouter.post('/download/certificate', async (c: Context) => { + try { + const data = await c.req.json(); + + const validatedData = ZCertificateDownload.parse(data); + + const result = await generateCertificate(validatedData); + + c.header('Content-Type', 'application/pdf'); + c.header( + 'Content-Disposition', + `attachment; filename="certificate-${validatedData.courseName}.pdf"` + ); + + return c.body( + new ReadableStream({ + start(controller) { + controller.enqueue(result); + controller.close(); + } + }) + ); + } catch (error) { + if (error instanceof z.ZodError) { + return c.json( + { + success: false, + error: 'Validation error', + details: error.errors + }, + 400 + ); + } + + console.error('Error generating certificate:', error); + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + details: error + }, + 500 + ); + } +}); + +courseRouter.post('/download/content', async (c: Context) => { + try { + const data = await c.req.json(); + const validatedData = ZCourseDownloadContent.parse(data); + + const pdfBuffer = await generateCoursePdf(validatedData); + + c.header('Content-Type', 'application/pdf'); + + return c.body( + new ReadableStream({ + start(controller) { + controller.enqueue(pdfBuffer); + controller.close(); + } + }) + ); + } catch (error) { + console.error('Error generating course PDF:', error); + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + details: error + }, + 400 + ); + } +}); diff --git a/apps/backend/src/routes/course/katex.ts b/apps/backend/src/routes/course/katex.ts new file mode 100644 index 00000000..15e36ea1 --- /dev/null +++ b/apps/backend/src/routes/course/katex.ts @@ -0,0 +1,48 @@ +import { Context, Hono } from 'hono'; + +import katex from 'katex'; +import { z } from 'zod'; + +export const katexRouter = new Hono(); + +const katexSchema = z.object({ + latex: z.string().min(1), + displayMode: z.boolean().optional() +}); + +katexRouter.post('/', async (c: Context) => { + try { + const data = await c.req.json(); + const validatedData = katexSchema.parse(data); + + const html = katex.renderToString(validatedData.latex, { + displayMode: validatedData.displayMode ?? false, + throwOnError: false + }); + + return c.json({ + success: true, + html + }); + } catch (error) { + if (error instanceof z.ZodError) { + return c.json( + { + success: false, + error: 'Validation error', + details: error.errors + }, + 400 + ); + } + + console.error('Error rendering LaTeX:', error); + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }, + 500 + ); + } +}); diff --git a/apps/backend/src/routes/course/lesson.ts b/apps/backend/src/routes/course/lesson.ts new file mode 100644 index 00000000..0638f85e --- /dev/null +++ b/apps/backend/src/routes/course/lesson.ts @@ -0,0 +1,35 @@ +import { Context, Hono } from 'hono'; +import { ZCertificateDownload, ZLessonDownloadContent } from '$src/types/course/lesson'; + +import { generateLessonPdf } from '$src/utils/lesson'; + +export const lessonRouter = new Hono(); + +lessonRouter.post('/download/pdf', async (c: Context) => { + try { + const data = await c.req.json(); + const validatedData = ZLessonDownloadContent.parse(data); + + const pdfBuffer = await generateLessonPdf(validatedData); + + c.header('Content-Type', 'application/pdf'); + return c.body( + new ReadableStream({ + start(controller) { + controller.enqueue(pdfBuffer); + controller.close(); + } + }) + ); + } catch (error) { + console.error('Error generating lesson PDF:', error); + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + details: error + }, + 400 + ); + } +}); diff --git a/apps/backend/src/routes/course/presign.ts b/apps/backend/src/routes/course/presign.ts new file mode 100644 index 00000000..412df8a3 --- /dev/null +++ b/apps/backend/src/routes/course/presign.ts @@ -0,0 +1,103 @@ +import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { ZCourseDownloadPresignedUrl, ZCoursePresignUrlUpload } from '$src/types/course'; + +import { CLOUDFLARE } from '$src/constants'; +import type { GetSignedUrlParameters } from '$src/utils/s3'; +import { Hono } from 'hono'; +import { authMiddleware } from '$src/middlewares/auth'; +import { generateFileKey } from '$src/utils/upload'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { s3Client } from '$src/utils/s3'; + +export const presignRouter = new Hono(); + +presignRouter.post('/upload', authMiddleware, async (c) => { + const body = await c.req.json(); + const result = ZCoursePresignUrlUpload.parse(body); + + const { fileName, fileType } = result; + const fileKey = generateFileKey(fileName); + + try { + const command = new PutObjectCommand({ + Bucket: CLOUDFLARE.R2.BUCKET, + Key: fileKey, + ContentType: fileType + }) as GetSignedUrlParameters[1]; + + const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, { + expiresIn: CLOUDFLARE.R2.PRESIGN_EXPIRATION_TIME + }); + + return c.json({ + success: true, + url: presignedUrl, + fileKey, + message: 'Pre-signed URL generated successfully' + }); + } catch (error) { + console.error('Error generating pre-signed URL:', error); + + if (error instanceof Error && error.name === 'SignatureDoesNotMatch') { + return c.json( + { + success: false, + type: 'SIGNATURE_ERROR', + message: 'Invalid signature for the request' + }, + 403 + ); + } + + return c.json( + { + success: false, + type: 'INTERNAL_ERROR', + message: 'Error generating pre-signed URL' + }, + 500 + ); + } +}); + +presignRouter.post('/download', authMiddleware, async (c) => { + const body = await c.req.json(); + const result = ZCourseDownloadPresignedUrl.parse(body); + + const { keys } = result; + + try { + const signedUrls: Record = {}; + + await Promise.all( + keys.map(async (key) => { + const command = new GetObjectCommand({ + Bucket: CLOUDFLARE.R2.BUCKET, + Key: key + }) as GetSignedUrlParameters[1]; + + const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, { + expiresIn: CLOUDFLARE.R2.DOWNLOAD_EXPIRATION_TIME + }); + + signedUrls[key] = presignedUrl; + }) + ); + + return c.json({ + success: true, + urls: signedUrls, + message: 'Video URLs retrieved successfully' + }); + } catch (error) { + console.error('Error Retrieving Video:', error); + return c.json( + { + success: false, + type: 'INTERNAL_ERROR', + message: 'Error Retrieving Video' + }, + 500 + ); + } +}); diff --git a/apps/backend/src/routes/downloadCertificate.js b/apps/backend/src/routes/downloadCertificate.js deleted file mode 100644 index d807f71b..00000000 --- a/apps/backend/src/routes/downloadCertificate.js +++ /dev/null @@ -1,35 +0,0 @@ -const express = require('express'); -const zod = require('zod'); -const { generateCertificate } = require('../utils/certificate'); - -const router = express.Router(); - -router.post('/', async (req, res) => { - try { - // Validate - const mySchema = zod.object({ - theme: zod.string(), - studentName: zod.string(), - courseName: zod.string(), - courseDescription: zod.string(), - orgLogoUrl: zod.string(), - orgName: zod.string(), - }); - console.log('req.body', req.body); - - mySchema.parse(req.body); - - const pdfBuffer = await generateCertificate(req.body); - - res.contentType('application/pdf'); - res.send(pdfBuffer); - } catch (error) { - console.error('Error', error); - res.json({ - success: false, - error, - }); - } -}); - -module.exports = router; diff --git a/apps/backend/src/routes/downloadCourse.js b/apps/backend/src/routes/downloadCourse.js deleted file mode 100644 index 42b694f2..00000000 --- a/apps/backend/src/routes/downloadCourse.js +++ /dev/null @@ -1,42 +0,0 @@ -const express = require('express'); -const zod = require('zod'); -const { generateCoursePdf } = require('../utils/course.js'); - -const router = express.Router(); - -// localhost:3002/downloadCourse -router.post('/', async (req, res) => { - try { - // Validate - const mySchema = zod.object({ - courseTitle: zod.string(), - orgName: zod.string(), - orgTheme: zod.string(), - lessons: zod.array( - zod.object({ - lessonTitle: zod.string(), - lessonNumber: zod.string(), - lessonNote: zod.string(), - slideUrl: zod.string().optional(), - video: zod.string().array().optional(), - }) - ), - }); - console.log('req.body', req.body); - - mySchema.parse(req.body); - - const pdfBuffer = await generateCoursePdf(req.body); - - res.contentType('application/pdf'); - res.send(pdfBuffer); - } catch (error) { - console.error('Error', error); - res.status(400).json({ - success: false, - error, - }); - } -}); - -module.exports = router; diff --git a/apps/backend/src/routes/downloadLesson.js b/apps/backend/src/routes/downloadLesson.js deleted file mode 100644 index 0cffe44c..00000000 --- a/apps/backend/src/routes/downloadLesson.js +++ /dev/null @@ -1,47 +0,0 @@ -const express = require('express'); -const zod = require('zod'); -const { generateLessonPdf } = require('../utils/lesson'); - -const router = express.Router(); - -const lesson = { - title: '', - number: '01', - orgName: '', - note: '', - slideUrl: '', - video: [], - courseTitle: '', -}; - -// localhost:3002/downloadLesson -router.post('/', async (req, res) => { - try { - // Validate - const mySchema = zod.object({ - title: zod.string(), - number: zod.string(), - orgName: zod.string(), - note: zod.string(), - slideUrl: zod.string().optional(), - video: zod.string().array().optional(), - courseTitle: zod.string(), - }); - console.log('req.body', req.body); - - mySchema.parse(req.body); - - const pdfBuffer = await generateLessonPdf(req.body); - - res.contentType('application/pdf'); - res.send(pdfBuffer); - } catch (error) { - console.error('Error', error); - res.status(400).json({ - success: false, - error, - }); - } -}); - -module.exports = router; diff --git a/apps/backend/src/routes/katex.js b/apps/backend/src/routes/katex.js deleted file mode 100644 index efc2a0bd..00000000 --- a/apps/backend/src/routes/katex.js +++ /dev/null @@ -1,31 +0,0 @@ -const express = require('express'); -// https://ben.page/node-latex-to-html -const katex = require('katex'); - -const router = express.Router(); - -// localhost:3002/katex -router.get('/', async (req, res) => { - try { - const original = req._parsedUrl.search; - - const latexString = original - .replace('?', '') - .replaceAll('+', '+') - .replaceAll('&space;', ''); - console.log({ latexString }); - - const html = katex.renderToString(latexString, { - throwOnError: false, - output: 'mathml' - }); - - console.log({ html }); - - res.send(html); - } catch (error) { - res.send('failed'); - } -}); - -module.exports = router; diff --git a/apps/backend/src/routes/mail.ts b/apps/backend/src/routes/mail.ts new file mode 100644 index 00000000..539886db --- /dev/null +++ b/apps/backend/src/routes/mail.ts @@ -0,0 +1,70 @@ +import { Context, Hono } from 'hono'; +import { sendWithNodemailer, sendWithZoho } from '$src/services/mail'; + +import { ZSendEmailValidation } from '$src/types/mail'; +import { env } from '$src/config/env'; +import { z } from 'zod'; + +export const mailRouter = new Hono(); + +mailRouter.post('/send', async (c: Context) => { + try { + const data = await c.req.json(); + const validatedData = ZSendEmailValidation.parse(data); + + 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 }); + } catch (error) { + if (error instanceof z.ZodError) { + return c.json( + { + success: false, + error: 'Validation error', + details: error.errors + }, + 400 + ); + } + + console.error('Error sending emails:', error); + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + details: error + }, + 500 + ); + } +}); diff --git a/apps/backend/src/routes/sendEmail.js b/apps/backend/src/routes/sendEmail.js deleted file mode 100644 index 487dcae4..00000000 --- a/apps/backend/src/routes/sendEmail.js +++ /dev/null @@ -1,112 +0,0 @@ -const express = require('express'); -const zod = require('zod'); -const { nodemailerTransporter, zohoClient } = require('../utils/email'); -const { withEmailTemplate } = require('../utils/withEmailTemplate'); -const { ZOHO_TOKEN } = process.env; - -const emailRouter = express.Router(); - -let transporter; - -nodemailerTransporter().then((t) => { - transporter = t; -}); - -async function sendWithNodemailer(emailData) { - const { from, to, subject, content, replyTo } = emailData; - - if (!transporter) { - return; - } - - return await transporter.sendMail({ - from: from || '"Best from ClassroomIO" ', - to, - subject, - replyTo, - html: withEmailTemplate(content) - }); -} - -// format: "ClassroomIO Developers (via ClassroomIO.com)" -function extractNameAndEmail(str) { - // Use regular expressions to match the name and email - const regex = /"(.*?)"\s+<\s*(.*?)@(.*?)\s*>/; - const match = str.match(regex); - - if (match) { - // Extract the name and email from the match groups - const name = match[1]; - const email = match[2] + '@' + match[3]; - return { name, email }; - } else { - // Return undefined if the format doesn't match - return { name: from, email: from }; - } -} - -async function sendWithZoho(emailData) { - const { from, to, subject, content } = emailData; - - const fromData = extractNameAndEmail(from); - - return zohoClient.sendMail({ - from: { - address: fromData.email, - name: fromData.name - }, - to: [ - { - email_address: { - address: to - } - } - ], - subject, - htmlbody: content - }); -} - -emailRouter.post('/', async (req, res) => { - try { - const mySchema = zod.array( - zod.object({ - from: zod.string().optional(), - to: zod.string(), - subject: zod.string(), - content: zod.string(), - replyTo: zod.string().optional() - }) - ); - - mySchema.parse(req.body); - - const emailDataArray = req.body; - - Promise.all( - emailDataArray.map(async (emailData) => { - let res; - - try { - if (!ZOHO_TOKEN) { - res = await sendWithNodemailer(emailData); - } else { - res = await sendWithZoho(emailData); - } - - console.log('Email status', res); - } catch (error) { - console.error('Error sending email', error, error?.error?.details?.[0]); - } - }) - ); - - console.log('Email sent'); - return res.json({ success: true }); - } catch (error) { - console.error('Error sending emails:', error); - return res.status(400).json({ error: `Bad request - ${error.message}` }); - } -}); - -module.exports = emailRouter; diff --git a/apps/backend/src/routes/uploadVideo.js b/apps/backend/src/routes/uploadVideo.js deleted file mode 100644 index 9780ba78..00000000 --- a/apps/backend/src/routes/uploadVideo.js +++ /dev/null @@ -1,278 +0,0 @@ -const express = require('express'); -const FormData = require('form-data'); -const axios = require('axios'); - -const { promisify } = require('util'); -const fs = require('fs'); -const unlinkAsync = promisify(fs.unlink); - -const { Upload } = require('@aws-sdk/lib-storage'); -const { - S3Client - // PutObjectCommand - // GetObjectCommand, -} = require('@aws-sdk/client-s3'); - -const { getSupabase } = require('../utils/supabase'); - -const genUniqueId = require('../utils/genUniqueId'); -const upload = require('../utils/upload'); - -const { - CLOUDFLARE_BUCKET_DOMAIN, - CLOUDFLARE_ACCESS_KEY, - CLOUDFLARE_SECRET_ACCESS_KEY, - CLOUDFLARE_ACCOUNT_ID, - CLOUDFLARE_BUCKET_ID, - MUSE_API_KEY -} = process.env; - -const router = express.Router(); - -const S3 = new S3Client({ - region: 'auto', - endpoint: `https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`, - credentials: { - accessKeyId: CLOUDFLARE_ACCESS_KEY, - secretAccessKey: CLOUDFLARE_SECRET_ACCESS_KEY - } -}); - -const supabase = getSupabase(); - -// const museUploadQueue = new Queue('video', process.env.REDIS_API_KEY, { -// redis: { tls: { rejectUnauthorized: false } }, -// }); - -router.post('/', upload.single('videoFile'), async (req, res) => { - // Set timeout for this request to 15 minutes - req.setTimeout(900000); - - const { - file, - query: { lessonId } - } = req; - console.log('lessonId', lessonId); - console.log('file', file); - - if (!file?.fieldname || !file?.fieldname) { - return res.status(400).json({ - success: false, - message: 'You must provide a file to upload' - }); - } - - // File upload limit is 500MB - const fileSizeInMegabytes = file?.size / (1024 * 1024); - - if (fileSizeInMegabytes > 500) { - return res.status(400).json({ - success: false, - type: 'FILE_TOO_LARGE', - message: 'File is too large' - }); - } - const fileData = fs.readFileSync(file.path); - - const fileName = `${genUniqueId()}-${file.originalname.split(' ').join('-')}`; - - const fileOrigin = CLOUDFLARE_BUCKET_DOMAIN ?? `https://pub-${CLOUDFLARE_BUCKET_ID}.r2.dev`; - - const fileUrl = `${fileOrigin}/${fileName}`; - let metadata = { - sizeInMb: fileSizeInMegabytes - }; - - try { - console.log('Upload starting'); - - console.time('upload'); - const parallelUploads3 = new Upload({ - client: S3, - queueSize: 5, - partSize: 1024 * 1024 * 20, - leavePartsOnError: false, // optional manually handle dropped parts - params: { - Bucket: 'videos', - Key: fileName, - Body: fileData - } - }); - - parallelUploads3.on('httpUploadProgress', (progress) => { - const uploadProgress = Math.round(((progress.loaded / progress.total) * 100) / 2); - console.log({ uploadProgress }); - const channel = supabase.channel('upload-progress'); - channel.subscribe((status) => { - if (status === 'SUBSCRIBED') { - channel.send({ - type: 'broadcast', - event: lessonId, - payload: uploadProgress - }); - } - }); - }); - - await parallelUploads3.done(); - - console.log('Upload to done'); - console.timeEnd('upload'); - - unlinkAsync(file.path); - - res.json({ - success: true, - url: fileUrl, - metadata, - message: 'Uploaded successfully' - }); - } catch (error) { - // error handling. - const { requestId, cfId, extendedRequestId } = error.$$metadata || {}; - - console.error('Upload failed', error); - console.log({ requestId, cfId, extendedRequestId }); - - res.json({ - success: false, - message: 'Uploaded failed: ' + error - }); - } finally { - // finally. - console.log('Upload complete'); - - // console.log('Add muse queue'); - // museUploadQueue.add({ - // fileName, - // originalFileName: file.originalname, - // lessonId, - // fileUrl, - // unlinkPath: file.path, - // }); - } -}); - -router.get('/', (req, res) => { - res.send('Get upload video'); -}); - -async function uploadToMuseNow(fileData, fileName) { - try { - // Add file data to form - const form = new FormData(); - form.append('file', fileData, fileName); - - // Send to muse through axios - fetch is a pain in the ass - const response = await axios({ - method: 'POST', - url: 'https://api.muse.ai/api/files/upload', - data: form, - maxContentLength: Infinity, - maxBodyLength: Infinity, - headers: { - Key: MUSE_API_KEY - } - }); - - return { - metadata: response.data - }; - } catch (error) { - console.log('Upload to Muse failed', error?.response?.data); - return {}; - } -} - -// /** -// * Uploads video to muse.ai and store response in DB -// * -// * @param {Blod} s3Data AWS s3 data -// * @param {string} fileName Name of the file -// * @param {string} lessonId Associated lesson this video was uploaded to -// * @param {string} fileUrl URL to the video in s3 -// * @param {string} unlinkPath Path to mutler file to delete -// */ -// async function uploadToMuse(s3Key, fileName, lessonId, fileUrl, unlinkPath) { -// try { -// await unlinkAsync(unlinkPath); - -// const s3Data = await S3.send( -// new GetObjectCommand({ -// Bucket: 'videos', -// Key: s3Key, -// }) -// ); -// // Add file data to form -// const form = new FormData(); -// form.append('file', s3Data.Body, fileName); - -// // Send to muse through axios - fetch is a pain in the ass -// const response = await axios({ -// method: 'POST', -// url: 'https://api.muse.ai/api/files/upload', -// data: form, -// maxContentLength: Infinity, -// maxBodyLength: Infinity, -// headers: { -// Key: MUSE_API_KEY, -// }, -// }); - -// console.log('Upload to muse complete', response.data); -// const { data: lesson } = await supabase -// .from('lesson') -// .select(`*`) -// .eq('id', lessonId) -// .single(); - -// if (lesson) { -// console.log('Found lesson'); -// // Add Muse response to metadata -// const videos = lesson.videos.map((v) => { -// if (v.link === fileUrl) { -// console.log('matches'); -// v.metadata = JSON.parse(JSON.stringify(response.data)); -// } -// return v; -// }); - -// // Save uploaded data to DB -// const { error } = await supabase -// .from('lesson') -// .update({ videos }) -// .eq('id', lessonId) -// .select(); -// if (error) { -// console.log('Error updating lesson', error); -// } else { -// console.log('Update DB complete'); -// } -// } -// } catch (error) { -// console.log('Upload to Muse failed', error?.response?.data); -// } -// } - -// // Queue to Upload to muse.ai -// museUploadQueue.process(async function (job, done) { -// console.log('Initilizing Muse Request\n\n', job.data); -// const { fileName, originalFileName, lessonId, fileUrl, unlinkPath } = -// job.data; - -// await uploadToMuse(fileName, originalFileName, lessonId, fileUrl, unlinkPath); - -// done(); -// }); - -// museUploadQueue.on('completed', function (job) { -// // Job completed with output result! -// console.log('Completed all jobs in uploading to muse', job.data); -// }); - -// museUploadQueue.on('error', function (error) { -// // Job completed with output result! -// console.log('error on queue', error); -// }); - -module.exports = router; diff --git a/apps/backend/src/services/mail.ts b/apps/backend/src/services/mail.ts new file mode 100644 index 00000000..2d46422e --- /dev/null +++ b/apps/backend/src/services/mail.ts @@ -0,0 +1,97 @@ +import { extractNameAndEmail, withEmailTemplate } from '$src/utils/mail'; +import { nodemailerTransporter, zohoClient } from '$src/utils/email'; + +import { Hono } from 'hono'; +import type { TSendEmailValidation } from '$src/types/mail'; +import type { Transporter } from 'nodemailer'; +import { env } from '$src/config/env'; + +export const mailRouter = new Hono(); + +interface EmailResponse { + success: boolean; + error?: string; + details?: unknown; +} + +let transporter: Transporter | null; + +nodemailerTransporter().then((t: Transporter | null) => { + transporter = t; +}); + +export async function sendWithNodemailer( + emailData: TSendEmailValidation[0] +): Promise { + const { from, to, subject, content, replyTo } = emailData; + + if (!transporter) { + return { + success: false, + error: 'Email transporter not initialized' + }; + } + + try { + const result = await transporter.sendMail({ + from: from || env.SMTP_SENDER, + to, + subject, + replyTo, + html: withEmailTemplate(content) + }); + + return { + success: true, + details: result + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + details: error + }; + } +} + +export async function sendWithZoho(emailData: TSendEmailValidation[0]): Promise { + const { from, to, subject, content } = emailData; + + const fromData = extractNameAndEmail(from || ''); + + if (!fromData) { + return { + success: false, + error: 'Invalid from data' + }; + } + + try { + const result = await zohoClient.sendMail({ + from: { + address: fromData.email, + name: fromData.name + }, + to: [ + { + email_address: { + address: to + } + } + ], + subject, + htmlbody: content + }); + + return { + success: true, + details: result + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + details: error + }; + } +} diff --git a/apps/backend/src/types/course/index.ts b/apps/backend/src/types/course/index.ts new file mode 100644 index 00000000..4ccb7062 --- /dev/null +++ b/apps/backend/src/types/course/index.ts @@ -0,0 +1,39 @@ +import { ALLOWED_CONTENT_TYPES, MAX_FILE_SIZE } from '$src/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; + +export const ZCoursePresignUrlUpload = z.object({ + fileName: z.string().min(1, 'File name is required'), + fileType: z.enum(ALLOWED_CONTENT_TYPES, { + errorMap: () => ({ + message: `Invalid content type. Allowed types: ${ALLOWED_CONTENT_TYPES.join(', ')}` + }) + }) +}); + +export type TCoursePresignUrlUpload = z.infer; + +export const ZCourseDownloadPresignedUrl = z.object({ + keys: z.array(z.string()).min(1, 'File names array is required') +}); + +export type TCourseDownloadPresignedUrl = z.infer; diff --git a/apps/backend/src/types/course/lesson.ts b/apps/backend/src/types/course/lesson.ts new file mode 100644 index 00000000..66f24905 --- /dev/null +++ b/apps/backend/src/types/course/lesson.ts @@ -0,0 +1,25 @@ +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; + +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; diff --git a/apps/backend/src/types/mail.ts b/apps/backend/src/types/mail.ts new file mode 100644 index 00000000..78116183 --- /dev/null +++ b/apps/backend/src/types/mail.ts @@ -0,0 +1,13 @@ +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; diff --git a/apps/backend/src/utils/auth/validate-user.ts b/apps/backend/src/utils/auth/validate-user.ts new file mode 100644 index 00000000..9ea20f2f --- /dev/null +++ b/apps/backend/src/utils/auth/validate-user.ts @@ -0,0 +1,21 @@ +import { User } from '@supabase/supabase-js'; +import { getSupabase } from '../supabase.js'; + +const supabase = getSupabase(); + +export async function validateUser(accessToken: string): Promise { + let user: User | null = null; + + try { + const { data } = await supabase.auth.getUser(accessToken); + user = data.user; + } catch (error) { + console.error(error); + } + + if (!user) { + throw new Error('Unauthenticated user'); + } + + return user; +} diff --git a/apps/backend/src/utils/certificate.js b/apps/backend/src/utils/certificate.js deleted file mode 100644 index d962c264..00000000 --- a/apps/backend/src/utils/certificate.js +++ /dev/null @@ -1,410 +0,0 @@ -const { getPdfBuffer } = require('./puppeteer'); - -function getCurrentDate() { - const date = new Date(); - const options = { year: 'numeric', month: 'long', day: 'numeric' }; - return date.toLocaleDateString(undefined, options); -} - -function getHtmlTemplate(body) { - return ` - - - - - - - - - - Certificate - - - - ${body} - - - `; -} - -function getProfessionalTheme({ - studentName, - courseName, - courseDescription, - orgLogoUrl, - orgName, - facilitator, - dateIssued -}) { - const themeBody = ` -
-
-
-

- Certificate
of completion -

-
-

This is to certify that

-

${studentName}

-
-
-

- has successfully completed introductory courses in -

-
-

- ${courseName} -

-
-
-

- ${courseDescription} -

-
-
-

${dateIssued}

-

Date Issued

-
- - - - -
-

-

Signature

-
-
-
- logo -

${orgName}

-
-
-
-
- `; - - return getHtmlTemplate(themeBody); -} - -function getPlainTheme({ - studentName, - courseName, - courseDescription, - orgLogoUrl, - orgName, - dateIssued -}) { - const themeBody = ` -
-
- logo -

${orgName}

-
-
-

- Certificate of completion -

-
-

This is to certify that

-

${studentName}

-
-
-

- has successfully completed a courses in - //

-
-

- ${courseName} -

-
-
-

${courseDescription}

-
-
-

${dateIssued}

-

Date Issued

-
- -
-

-

Signature

-
-
-
-
- `; - - return getHtmlTemplate(themeBody); -} - -function getPurpleProfessionalBadge({ - studentName, - courseName, - courseDescription, - orgLogoUrl, - orgName, - facilitator, - dateIssued -}) { - const themeBody = ` - -
-
- -

${orgName}

-
- -
-

This certificate is awarded to

-

${studentName}

-
- -
-

has succesfully completed training on

-

- ${courseName} -

-
- -
-
-

${dateIssued}

-

Date Issued

-
- -
-

-

Signature

-
-
- -
- ${courseDescription} -
-
- `; - - return getHtmlTemplate(themeBody); -} - -function getBlueProfessionalBadge({ - studentName, - courseName, - courseDescription, - orgLogoUrl, - orgName, - facilitator, - dateIssued -}) { - const themeBody = ` - -
-
- -

${orgName}

-
- -
-

This certificate is awarded to

-

${studentName}

-
- -
-

has succesfully completed training on

-

- ${courseName} -

-
- -
-
-

${dateIssued}

-

Date Issued

-
- -
-

-

Signature

-
-
- -
- ${courseDescription} -
-
- `; - - return getHtmlTemplate(themeBody); -} - -function getPurpleProfessionalBadgePattern({ - studentName, - courseName, - courseDescription, - orgLogoUrl, - orgName, - dateIssued -}) { - const themeBody = ` - -
-
- -

${orgName}

-
- -
-

This certificate is awarded to

-

${studentName}

-
- -
-

has succesfully completed training on

-

- ${courseName} -

-
- -
-
-

${dateIssued}

-

Date Issued

-
- -
-

-

Signature

-
-
- -
- ${courseDescription} -
-
- `; - - return getHtmlTemplate(themeBody); -} - -function getBlueProfessionalBadgePattern({ - studentName, - courseName, - courseDescription, - orgLogoUrl, - orgName, - dateIssued -}) { - const themeBody = ` - -
-
- -

${orgName}

-
- -
-

This certificate is awarded to

-

${studentName}

-
- -
-

has succesfully completed training on

-

- ${courseName} -

-
- -
-
-

${dateIssued}

-

Date Issued

-
- -
-

-

Signature

-
-
- -
- ${courseDescription} -
-
- `; - - return getHtmlTemplate(themeBody); -} - -const generateCertificate = async ({ - theme, - studentName, - courseName, - courseDescription, - orgLogoUrl, - orgName, - facilitator -}) => { - const dateIssued = getCurrentDate(); - - const params = { - studentName, - courseName, - courseDescription, - orgLogoUrl, - orgName, - facilitator, - dateIssued - }; - console.log('theme', theme); - - const themeFunctions = { - plain: getPlainTheme, - professional: getProfessionalTheme, - purpleProfessionalBadge: getPurpleProfessionalBadge, - blueProfessionalBadge: getBlueProfessionalBadge, - purpleBadgePattern: getPurpleProfessionalBadgePattern, - blueBadgePattern: getBlueProfessionalBadgePattern - }; - - // set plain (getPlainTheme) as the default theme - const selectedThemeFunction = themeFunctions[theme] || getPlainTheme; - const html = selectedThemeFunction(params); - console.log('body', html); - - return await getPdfBuffer(html); -}; - -module.exports = { - generateCertificate -}; diff --git a/apps/backend/src/utils/certificate.ts b/apps/backend/src/utils/certificate.ts new file mode 100644 index 00000000..0a0f3a92 --- /dev/null +++ b/apps/backend/src/utils/certificate.ts @@ -0,0 +1,118 @@ +import type { TCertificateDownload } from '$src/types/course/lesson'; +import { getPdfBuffer } from '$src/utils/puppeteer'; + +export interface CertificateData { + theme: string; + studentName: string; + courseName: string; + courseDescription: string; + orgLogoUrl: string; + orgName: string; + facilitator?: string; +} + +export interface CertificateGenerationResult { + success: boolean; + buffer?: Buffer; + error?: string; +} + +export const generateCertificate = async (data: TCertificateDownload): Promise => { + const html = generateCertificateHtml(data); + return getPdfBuffer(html); +}; + +function generateCertificateHtml(data: TCertificateDownload): string { + const { studentName, courseName, courseDescription, orgLogoUrl, orgName, facilitator } = data; + + return ` + + + + + Certificate of Completion + + + +
+
+ +

Certificate of Completion

+
+
+

${studentName}

+

${courseName}

+

${courseDescription}

+
+ +
+ + + `; +} diff --git a/apps/backend/src/utils/course.js b/apps/backend/src/utils/course.ts similarity index 57% rename from apps/backend/src/utils/course.js rename to apps/backend/src/utils/course.ts index 342862ce..200b2261 100644 --- a/apps/backend/src/utils/course.js +++ b/apps/backend/src/utils/course.ts @@ -1,8 +1,7 @@ -// const { getPdfBuffer } = require('./puppeteer'); -const { marked } = require('marked'); -// const puppeteer = require('puppeteer'); +import type { TCourseDownloadContent } from '$src/types/course'; +import { marked } from 'marked'; -function getHtmlTemplate(body) { +function getHtmlTemplate(body: string): string { return ` @@ -71,22 +70,20 @@ function getHtmlTemplate(body) { `; } +type TLessonBody = TCourseDownloadContent['lessons'][0]; + function getLessonBody( - { lessonTitle, lessonNumber, lessonNote, slideUrl, video }, - orgName, - orgTheme -) { + { lessonTitle, lessonNumber, lessonNote, slideUrl, video }: TLessonBody, + orgName: string, + orgTheme: string +): string { const noteHtml = marked.parse(lessonNote); const showExtraResources = slideUrl || (video && video.length > 0); return `
+ class="bg-[${orgTheme ? orgTheme : '#0030FF'}] w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">

+ class="my-0 rounded-full w-10 text-center mx-auto py-1 text-[${orgTheme ? orgTheme : '#0030FF'}] bg-white text-md font-bold tracking-tighter"> ${lessonNumber}

${lessonTitle}

@@ -140,83 +137,34 @@ function getLessonBody( `; } -function getCourseBody({ courseTitle, orgName, orgTheme, lessons }) { +function getCourseBody({ + courseTitle, + orgName, + orgTheme, + lessons +}: TCourseDownloadContent): string { return ` -
+

${courseTitle}

${orgName}

- ${lessons.map((lesson) => getLessonBody(lesson, orgName, orgTheme))} + ${lessons.map((lesson) => getLessonBody(lesson, orgName, orgTheme)).join('')}
`; } -async function generateSinglePdfFromHtml(htmlContent, courseTitle, orgTheme) { +async function generateSinglePdfFromHtml( + htmlContent: string, + courseTitle: string, + orgTheme: string +): Promise { throw 'Download disabled'; - // const args = [ - // '--disable-web-security', - // '--no-sandbox', - // '--disable-setuid-sandbox', - // ]; - // const browser = await puppeteer.launch({ - // headless: 'new', - // args, - // }); - // const page = await browser.newPage(); - - // await page.setContent(htmlContent); - - // const pdfOptions = { - // format: 'A4', - // printBackground: true, - // displayHeaderFooter: true, - // headerTemplate: - // "
/
", - // footerTemplate: ` - // - // `, - // margin: { - // top: '0.3cm', - // bottom: '0.5cm', - // }, - // }; - - // const numPages = await page.evaluate(() => { - // const content = document.body; // Change this to the specific element containing content - // const style = window.getComputedStyle(content); - // const contentHeight = parseInt(style.height, 10); - // const pageHeight = 900; // Set this to an appropriate value based on your PDF format - - // return Math.ceil(contentHeight / pageHeight); - // }); - - // const pagesPdfBuffer = []; - - // for (let currentPage = 1; currentPage <= numPages; currentPage++) { - // await page.evaluate(() => {}); - - // const pdfBuffer = await page.pdf(pdfOptions); - // pagesPdfBuffer.push(pdfBuffer); - // } - - // await browser.close(); - // return Buffer.concat(pagesPdfBuffer); } -async function generateCoursePdf(params) { - // Generate the course boddy +export async function generateCoursePdf(params: TCourseDownloadContent): Promise { const body = getCourseBody(params); const html = getHtmlTemplate(body); console.log(html); - return await generateSinglePdfFromHtml(html, params.courseTitle); + return await generateSinglePdfFromHtml(html, params.courseTitle, params.orgTheme); } - -module.exports = { - generateCoursePdf -}; diff --git a/apps/backend/src/utils/email.js b/apps/backend/src/utils/email.js deleted file mode 100644 index 2e2d4662..00000000 --- a/apps/backend/src/utils/email.js +++ /dev/null @@ -1,32 +0,0 @@ -const { SendMailClient } = require('zeptomail'); -const nodemailer = require('nodemailer'); - -const { ZOHO_TOKEN, SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD } = process.env; - -const zohoClient = new SendMailClient({ url: 'api.zeptomail.eu/', token: ZOHO_TOKEN }); - -const nodemailerTransporter = async () => { - if (!SMTP_HOST || !SMTP_PORT || !SMTP_USER || !SMTP_PASSWORD) { - console.error('SMTP configuration missing'); - return null; - } - - try { - const transporter = nodemailer.createTransport({ - host: SMTP_HOST, - port: parseInt(SMTP_PORT || '465', 10), - secure: true, - auth: { - user: SMTP_USER, - pass: SMTP_PASSWORD - } - }); - await transporter.verify(); - return transporter; - } catch (error) { - console.error('Transporter error:', error); - return null; - } -}; - -module.exports = { nodemailerTransporter, zohoClient }; diff --git a/apps/backend/src/utils/email.ts b/apps/backend/src/utils/email.ts new file mode 100644 index 00000000..3a52bf81 --- /dev/null +++ b/apps/backend/src/utils/email.ts @@ -0,0 +1,35 @@ +import { SendMailClient } from 'zeptomail'; +import type { Transporter } from 'nodemailer'; +import { env } from '$src/config/env'; +import nodemailer from 'nodemailer'; + +export const zohoClient = new SendMailClient({ + url: 'api.zeptomail.eu/', + token: env.ZOHO_TOKEN! +}); + +export const nodemailerTransporter = async (): Promise => { + 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; + } +}; diff --git a/apps/backend/src/utils/genUniqueId.js b/apps/backend/src/utils/genUniqueId.ts similarity index 59% rename from apps/backend/src/utils/genUniqueId.js rename to apps/backend/src/utils/genUniqueId.ts index 7e8dc510..b45b91f3 100644 --- a/apps/backend/src/utils/genUniqueId.js +++ b/apps/backend/src/utils/genUniqueId.ts @@ -1,3 +1,3 @@ -module.exports = () => { +export default function genUniqueId(): string { return Date.now().toString(36) + Math.random().toString(36).substr(2); -}; +} diff --git a/apps/backend/src/utils/lesson.js b/apps/backend/src/utils/lesson.ts similarity index 58% rename from apps/backend/src/utils/lesson.js rename to apps/backend/src/utils/lesson.ts index 29634fef..4f4c6241 100644 --- a/apps/backend/src/utils/lesson.js +++ b/apps/backend/src/utils/lesson.ts @@ -1,8 +1,7 @@ -// const { getPdfBuffer } = require('./puppeteer'); -const { marked } = require('marked'); -// const puppeteer = require('puppeteer'); +import type { TLessonDownloadContent } from '$src/types/course/lesson'; +import { marked } from 'marked'; -function getHtmlTemplate(body) { +function getHtmlTemplate(body: string): string { return ` @@ -75,7 +74,15 @@ function getHtmlTemplate(body) { `; } -function getLessonBody({ title, number, orgName, note, slideUrl, video, courseTitle }) { +function getLessonBody({ + title, + number, + orgName, + note, + slideUrl, + video, + courseTitle +}: TLessonDownloadContent): string { const noteHtml = marked.parse(note); const showExtraResources = slideUrl || (video && video.length > 0); return ` @@ -139,83 +146,16 @@ function getLessonBody({ title, number, orgName, note, slideUrl, video, courseTi `; } -async function generateSinglePdfFromHtml(htmlContent, courseTitle) { - // const args = [ - // '--disable-web-security', - // '--no-sandbox', - // '--disable-setuid-sandbox', - // ]; - // const browser = await puppeteer.launch({ - // headless: 'new', - // args, - // }); - // const page = await browser.newPage(); - - // await page.setContent(htmlContent); - - // const pdfOptions = { - // format: 'A4', - // printBackground: true, - // displayHeaderFooter: true, - // headerTemplate: - // "
/
", - // footerTemplate: ``, - // margin: { - // top: '0.4cm', - // bottom: '1cm', - // }, - // }; - - // const numPages = await page.evaluate(() => { - // const content = document.body; // Change this to the specific element containing content - // const style = window.getComputedStyle(content); - // const contentHeight = parseInt(style.height, 10); - // const pageHeight = 900; // Set this to an appropriate value based on your PDF format - - // return Math.ceil(contentHeight / pageHeight); - // }); - - // const pagesPdfBuffer = []; - - // for (let currentPage = 1; currentPage <= numPages; currentPage++) { - // await page.evaluate(() => {}); - - // const pdfBuffer = await page.pdf(pdfOptions); - // pagesPdfBuffer.push(pdfBuffer); - // } - - // await browser.close(); - // return Buffer.concat(pagesPdfBuffer); - +async function generateSinglePdfFromHtml( + htmlContent: string, + courseTitle: string +): Promise { throw 'Download disabled'; } -async function generateLessonPdf(params) { - // Generate the lesson boddy +export async function generateLessonPdf(params: TLessonDownloadContent): Promise { const body = getLessonBody(params); const html = getHtmlTemplate(body); console.log(html); return await generateSinglePdfFromHtml(html, params.courseTitle); - // return await getPdfBuffer(html, { - // path: 'test.pdf', - // width: '1000px', - // height: undefined, - // printBackground: true, - // format: 'A4', - // displayHeaderFooter: true, - // headerTemplate: - // "
/
", - // footerTemplate: - // '
of
', - // margin: { - // top: '1cm', - // bottom: '1cm', - // }, - // }); } - -module.exports = { - generateLessonPdf -}; diff --git a/apps/backend/src/utils/withEmailTemplate.js b/apps/backend/src/utils/mail.ts similarity index 85% rename from apps/backend/src/utils/withEmailTemplate.js rename to apps/backend/src/utils/mail.ts index cdf67df3..99525b6e 100644 --- a/apps/backend/src/utils/withEmailTemplate.js +++ b/apps/backend/src/utils/mail.ts @@ -1,4 +1,26 @@ -const withEmailTemplate = (content) => +interface FromData { + name: string; + email: string; +} + +// format: "ClassroomIO Developers (via ClassroomIO.com)" +export function extractNameAndEmail(str: string): FromData | undefined { + // Use regular expressions to match the name and email + const regex = /"(.*?)"\s+<\s*(.*?)@(.*?)\s*>/; + const match = str.match(regex); + + if (match) { + // Extract the name and email from the match groups + const name = match[1]; + const email = match[2] + '@' + match[3]; + return { name, email }; + } else { + // Return undefined if the format doesn't match + return { name: str, email: str }; + } +} + +export const withEmailTemplate = (content: string): string => ` @@ -189,4 +211,3 @@ const withEmailTemplate = (content) => `; -module.exports = { withEmailTemplate }; diff --git a/apps/backend/src/utils/puppeteer.js b/apps/backend/src/utils/puppeteer.js deleted file mode 100644 index db0c2fec..00000000 --- a/apps/backend/src/utils/puppeteer.js +++ /dev/null @@ -1,39 +0,0 @@ -// const puppeteer = require('puppeteer'); - -const getPdfBuffer = async (html, pdfParams) => { - throw 'Puppeteer disabled'; - // const args = [ - // '--disable-web-security', - // '--no-sandbox', - // '--disable-setuid-sandbox', - // ]; - // const defaultPdfParams = { - // width: '1123px', - // height: '794px', - // }; - - // // launch a new chrome instance - // const browser = await puppeteer.launch({ - // headless: 'new', - // args, - // }); - - // // create a new page - // const page = await browser.newPage(); - // await page.setViewport({ width: 1123, height: 794 }); - // await page.goto('data:text/html,' + html, { waitUntil: 'networkidle2' }); - // await page.addStyleTag({ content: '@page { size: auto; }' }); - // await page.emulateMediaType('screen'); - - // // create a pdf buffer - // const pdfBuffer = await page.pdf(pdfParams || defaultPdfParams); - - // // close the browser - // await browser.close(); - - // return pdfBuffer; -}; - -module.exports = { - getPdfBuffer -}; diff --git a/apps/backend/src/utils/puppeteer.ts b/apps/backend/src/utils/puppeteer.ts new file mode 100644 index 00000000..1efdd2ae --- /dev/null +++ b/apps/backend/src/utils/puppeteer.ts @@ -0,0 +1,75 @@ +import puppeteer, { type PDFOptions } from 'puppeteer'; + +export interface PdfParams extends Partial { + width?: string; + height?: string; + format?: + | 'Letter' + | 'Legal' + | 'Tabloid' + | 'Ledger' + | 'A0' + | 'A1' + | 'A2' + | 'A3' + | 'A4' + | 'A5' + | 'A6'; + landscape?: boolean; + printBackground?: boolean; + margin?: { + top?: string; + right?: string; + bottom?: string; + left?: string; + }; +} + +export interface PdfGenerationResult { + success: boolean; + buffer?: Buffer; + error?: string; +} + +export const getPdfBuffer = async (html: string, pdfParams?: PdfParams) => { + const args = ['--disable-web-security', '--no-sandbox', '--disable-setuid-sandbox']; + + const defaultPdfParams: PdfParams = { + width: '1123px', + height: '794px', + format: 'A4', + printBackground: true, + margin: { + top: '0', + right: '0', + bottom: '0', + left: '0' + } + }; + + try { + // launch a new chrome instance + const browser = await puppeteer.launch({ + headless: true, + args + }); + + // create a new page + const page = await browser.newPage(); + await page.setViewport({ width: 1123, height: 794 }); + await page.goto('data:text/html,' + html, { waitUntil: 'networkidle2' }); + await page.addStyleTag({ content: '@page { size: auto; }' }); + await page.emulateMediaType('screen'); + + // create a pdf buffer + const pdfBuffer = await page.pdf(pdfParams || defaultPdfParams); + + // close the browser + await browser.close(); + + return Buffer.from(pdfBuffer); + } catch (error) { + console.error('Error generating PDF:', error); + throw new Error(error instanceof Error ? error.message : 'Failed to generate PDF'); + } +}; diff --git a/apps/backend/src/utils/s3.ts b/apps/backend/src/utils/s3.ts new file mode 100644 index 00000000..56b56d05 --- /dev/null +++ b/apps/backend/src/utils/s3.ts @@ -0,0 +1,56 @@ +import { + DeleteObjectCommand, + DeleteObjectCommandInput, + GetObjectCommand, + GetObjectCommandInput, + PutObjectCommand, + PutObjectCommandInput, + S3Client +} from '@aws-sdk/client-s3'; + +import { CLOUDFLARE } from '$src/constants'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; + +export type GetSignedUrlParameters = Parameters; + +export const s3Client = new S3Client({ + region: 'auto', + endpoint: `https://${CLOUDFLARE.CONFIGS.ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: CLOUDFLARE.CONFIGS.ACCESS_KEY, + secretAccessKey: CLOUDFLARE.CONFIGS.SECRET_ACCESS_KEY + } +}); + +export async function uploadToS3( + params: PutObjectCommandInput +): Promise<{ success: boolean; error?: string }> { + try { + await s3Client.send(new PutObjectCommand(params)); + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } +} + +export async function getFromS3( + params: GetObjectCommandInput +): Promise<{ success: boolean; data?: any; error?: string }> { + try { + const data = await s3Client.send(new GetObjectCommand(params)); + return { success: true, data }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } +} + +export async function deleteFromS3( + params: DeleteObjectCommandInput +): Promise<{ success: boolean; error?: string }> { + try { + await s3Client.send(new DeleteObjectCommand(params)); + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } +} diff --git a/apps/backend/src/utils/supabase.js b/apps/backend/src/utils/supabase.js deleted file mode 100644 index 9133f889..00000000 --- a/apps/backend/src/utils/supabase.js +++ /dev/null @@ -1,30 +0,0 @@ -const { createClient } = require('@supabase/supabase-js'); - -const { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } = process.env; - -let supabase; - -const getSupabase = (config) => { - if (supabase) return supabase; - - config = { - supabaseConfig: { - url: PUBLIC_SUPABASE_URL, - anonKey: PUBLIC_SUPABASE_ANON_KEY - } - }; - - try { - supabase = createClient(config.supabaseConfig.url, config.supabaseConfig.anonKey, { - auth: { persistSession: false } - }); - } catch (error) { - console.error('Error starting supabase', error) - } - - return supabase; -}; - -module.exports = { - getSupabase -}; diff --git a/apps/backend/src/utils/supabase.ts b/apps/backend/src/utils/supabase.ts new file mode 100644 index 00000000..cb0e461f --- /dev/null +++ b/apps/backend/src/utils/supabase.ts @@ -0,0 +1,36 @@ +import { SupabaseClient, createClient } from '@supabase/supabase-js'; + +import { env } from '$src/config/env'; + +const { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } = env; + +let supabase: SupabaseClient | null = null; + +interface SupabaseConfig { + supabaseConfig: { + url: string; + anonKey: string; + }; +} + +export function getSupabase(config?: SupabaseConfig): SupabaseClient { + if (supabase) return supabase; + + const finalConfig = config ?? { + supabaseConfig: { + url: PUBLIC_SUPABASE_URL, + anonKey: PUBLIC_SUPABASE_ANON_KEY + } + }; + + try { + supabase = createClient(finalConfig.supabaseConfig.url, finalConfig.supabaseConfig.anonKey, { + auth: { persistSession: false } + }); + } catch (error) { + console.error('Error starting supabase', error); + throw error; + } + + return supabase; +} diff --git a/apps/backend/src/utils/upload.js b/apps/backend/src/utils/upload.js deleted file mode 100644 index ae7e04f9..00000000 --- a/apps/backend/src/utils/upload.js +++ /dev/null @@ -1,17 +0,0 @@ -const multer = require('multer'); - -// const storage = multer.memoryStorage(); -// const upload = multer({ storage: storage }); - -const storage = multer.diskStorage({ - destination: function (req, file, cb) { - cb(null, 'uploads/'); - }, - filename: function (req, file, cb) { - cb(null, `${new Date().getTime()}-${file.originalname}`); //Appending extension - }, -}); - -var upload = multer({ storage: storage }); - -module.exports = upload; diff --git a/apps/backend/src/utils/upload.ts b/apps/backend/src/utils/upload.ts new file mode 100644 index 00000000..99cd0198 --- /dev/null +++ b/apps/backend/src/utils/upload.ts @@ -0,0 +1,24 @@ +import { nanoid } from 'nanoid'; +import path from 'path'; + +/** + * Safely extracts the file extension from a filename. + * Returns an empty string if no extension is found. + */ +export function getExtension(filename: string): string { + if (!filename || typeof filename !== 'string') return ''; + const ext = path.extname(filename).split('.'); + return ext.length > 1 ? ext[ext.length - 1] : ''; +} + +/** + * Generates a unique file key for storage, preserving the file extension. + * Throws an error if the filename is invalid or has no extension. + */ +export function generateFileKey(fileName: string): string { + const ext = getExtension(fileName); + if (!ext) { + throw new Error('Invalid file name or missing file extension'); + } + return `${nanoid()}.${ext}`; +} diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json new file mode 100644 index 00000000..eb9e8baf --- /dev/null +++ b/apps/backend/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "baseUrl": "./", + "esModuleInterop": true, + "module": "ESNext", + "moduleResolution": "Node", + "outDir": "./dist", + "paths": { + "$src": ["./src"], + "$src/*": ["./src/*"] + }, + "resolveJsonModule": false, + "skipLibCheck": true, + "sourceMap": true, + "target": "ESNext", + "typeRoots": ["./node_modules/@types", "./src/types"], + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "tsc-alias": { + "resolveFullPaths": true, + "verbose": false + } +} diff --git a/apps/backend/uploads/.empty b/apps/backend/uploads/.empty deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/backend/vitest.config.ts b/apps/backend/vitest.config.ts new file mode 100644 index 00000000..f29f8b2a --- /dev/null +++ b/apps/backend/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: ['node_modules/', 'dist/'] + } + } +}); diff --git a/apps/dashboard/src/lib/components/Course/components/Ceritficate/StudentCertificate/UnlockedCertificate.svelte b/apps/dashboard/src/lib/components/Course/components/Ceritficate/StudentCertificate/UnlockedCertificate.svelte index 0f681e2e..febe70cd 100644 --- a/apps/dashboard/src/lib/components/Course/components/Ceritficate/StudentCertificate/UnlockedCertificate.svelte +++ b/apps/dashboard/src/lib/components/Course/components/Ceritficate/StudentCertificate/UnlockedCertificate.svelte @@ -13,6 +13,7 @@ import { fetchProfileCourseProgress } from '$lib/utils/services/courses'; import type { ProfileCourseProgress } from '$lib/utils/types'; import { snackbar } from '$lib/components/Snackbar/store'; + import { apiClient } from '$lib/utils/services/api'; let isLoading = false; let isCourseComplete = false; @@ -23,24 +24,22 @@ isLoading = true; try { - const response = await fetch(env.PUBLIC_SERVER_URL + '/downloadCertificate', { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ + const response = await apiClient.post( + '/course/download/certificate', + { theme: `${$course.certificate_theme}`, studentName: `${$profile.fullname}`, courseName: `${$course.title}`, courseDescription: `${$course.description}`, orgLogoUrl: `${$currentOrg.avatar_url}`, orgName: `${$currentOrg.name}` - }) - }); - const data = await response.blob(); - console.log(data); - const file = new Blob([data], { type: 'application/pdf' }); + }, + { + responseType: 'blob' + } + ); + + const file = new Blob([response.data], { type: 'application/pdf' }); const fileURL = URL.createObjectURL(file); let a = document.createElement('a'); @@ -85,12 +84,12 @@ -
+
Certificate -

+

{$t(title)}

-

+

{$t(subtitle)}

+ -
-
-

+

+
+

{$t('course.navItem.lessons.materials.tabs.video.add_video.add_by')}

{#each tabs as item (item.value)} {/each}
-
+
{#if currentTab === 1} {:else if currentTab === 2} {:else} - + {/if}
diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/LocalVideo.svelte b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/UploadVideo.svelte similarity index 70% rename from apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/LocalVideo.svelte rename to apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/UploadVideo.svelte index 06fa4af3..81b3b2d9 100644 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/LocalVideo.svelte +++ b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/Video/UploadVideo.svelte @@ -1,69 +1,96 @@ ($uploadCourseVideoStore.isModalOpen = false)}> @@ -129,16 +126,16 @@ {#if !isLoaded}