chore: move backend to hono + typescript (#558)

* feat: setup secure presigned uploads and downloads

* clean up

* update lock

* Get presigned url finally working

* 557: Migrate backend to hono + ts

* chore: update Dockerfile and package.json for backend configuration

* add dev command

* remove unused packages

* Enhance backend API and frontend components for course management

- Updated CORS configuration in the backend to allow specific methods and headers.
- Refactored lesson download endpoint to use a new route for PDF downloads.
- Removed unused certificate download functionality from the lesson router.
- Updated frontend components to reflect changes in API endpoints and improve video upload functionality.
- Introduced new video upload component and adjusted video handling in lesson materials.
- Cleaned up unnecessary imports and optimized component structure for better performance.

* PR cleanups

* Enhance email configuration by adding SMTP_SENDER environment variable

- Updated docker-compose.yaml to include SMTP_SENDER.
- Modified Dockerfile to accept SMTP_SENDER as an argument.
- Updated environment schema in env.ts to define SMTP_SENDER as optional.
- Adjusted mail service to use SMTP_SENDER for the email 'from' field.
- Updated documentation to reflect the new SMTP_SENDER variable.

* Remove debug log statement from sendEmail function

---------

Co-authored-by: Iniubong Obonguko <[email protected]>
This commit is contained in:
Rotimi Best
2025-06-09 01:38:57 +01:00
committed by GitHub
co-authored by Iniubong Obonguko
parent 9eb7b01587
commit 82be557a42
80 changed files with 3291 additions and 2302 deletions
+20
View File
@@ -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);
+25
View File
@@ -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;
+8
View File
@@ -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
+66
View File
@@ -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();
+44
View File
@@ -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 <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(
{
success: false,
message: 'Unauthorized'
},
401
);
}
};
+91
View File
@@ -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
);
}
});
+48
View File
@@ -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
);
}
});
+35
View File
@@ -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
);
}
});
+103
View File
@@ -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<string, string> = {};
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
);
}
});
@@ -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;
-42
View File
@@ -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;
-47
View File
@@ -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;
-31
View File
@@ -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('&plus;', '+')
.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;
+70
View File
@@ -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
);
}
});
-112
View File
@@ -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" <[email protected]>',
to,
subject,
replyTo,
html: withEmailTemplate(content)
});
}
// format: "ClassroomIO Developers (via ClassroomIO.com)" <[email protected]>
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;
-278
View File
@@ -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;
+97
View File
@@ -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<EmailResponse> {
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<EmailResponse> {
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
};
}
}
+39
View File
@@ -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<typeof ZCourseDownloadContent>;
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<typeof ZCoursePresignUrlUpload>;
export const ZCourseDownloadPresignedUrl = z.object({
keys: z.array(z.string()).min(1, 'File names array is required')
});
export type TCourseDownloadPresignedUrl = z.infer<typeof ZCourseDownloadPresignedUrl>;
+25
View File
@@ -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<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>;
+13
View File
@@ -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<typeof ZSendEmailValidation>;
@@ -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<User> {
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;
}
-410
View File
@@ -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 `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.tailwindcss.com"></script>
<title>Certificate</title>
<style>
body {
width: 1123px;
height: 390px;
}
.student-name {
font-family: 'Qwitcher Grypen', cursive;
}
</style>
</head>
<body>
${body}
</body>
</html>
`;
}
function getProfessionalTheme({
studentName,
courseName,
courseDescription,
orgLogoUrl,
orgName,
facilitator,
dateIssued
}) {
const themeBody = `
<div class="h-[794px] flex items-center justify-center border border-dashed border-blue-400 w-full">
<div class="w-full h-full flex border-l-8 border-blue-800">
<div class="h-full w-full px-5 py-10">
<p class="text-2xl font-normal my-2 mb-10 uppercase text-gray-500">
Certificate<br /> of completion
</p>
<div class="border-b border-gray-500 mb-4">
<p class="text-xl text-black font-normal">This is to certify that</p>
<p class=" text-center text-9xl student-name">${studentName}</p>
</div>
<div class="mb-2">
<p class="text-xl text-black font-normal">
has successfully completed introductory courses in
</p>
<div>
<p class="bg-transparent font-semibold text-blue-800 text-center uppercase my-10 text-3xl">
${courseName}
</p>
</div>
</div>
<p class="text-xl font-light mb-4 text-justify">
${courseDescription}
</p>
<div class="flex justify-between w-[70%] mx-auto text-center items-end my-14">
<div>
<p class="font-bold">${dateIssued}</p>
<h1 class="border-t border-black h-2/4 font-bold">Date Issued</h1>
</div>
<!-- <div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">${facilitator}</p>
</div>-->
<div class="w-[20%]">
<h1 class="h-2/4 border-b border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<div class="flex items-center gap-1 mt-14">
<img src="${orgLogoUrl}" alt="logo" class="w-24 h-24 rounded-md" />
<p class="font-semibold capitalize text-5xl">${orgName}</p>
</div>
</div>
</div>
</div>
`;
return getHtmlTemplate(themeBody);
}
function getPlainTheme({
studentName,
courseName,
courseDescription,
orgLogoUrl,
orgName,
dateIssued
}) {
const themeBody = `
<div class="h-[794px] flex flex-col items-center justify-center border-4 border-pink-700 py-3 w-full">
<div class="flex items-center gap-1 my-2 mb-10">
<img src="${orgLogoUrl}" alt="logo" class="w-24 h-24 rounded-md" />
<p class="font-semibold capitalize text-5xl">${orgName}</p>
</div>
<div class="w-full px-5 py-3">
<p class="text-xl text-center font-normal my-2 uppercase tracking-widest text-gray-500 w-full">
Certificate of completion
</p>
<div class="border-b border-gray-500 mb-4">
<p class="text-xl text-black font-normal">This is to certify that</p>
<p class=" text-center text-8xl student-name">${studentName}</p>
</div>
<div class="mb-2">
<p class="text-xl text-black font-normal">
has successfully completed a courses in
// </p>
<div class="my-10">
<p class="bg-transparent font-semibold text-pink-700 text-center uppercase text-3xl">
${courseName}
</p>
</div>
</div>
<p class="text-xl text-center text-gray-500 font-light">${courseDescription}</p>
<div class="flex justify-between w-[70%] mx-auto text-center items-end my-14">
<div>
<p class="font-bold">${dateIssued}</p>
<h1 class="border-t border-black h-2/4 font-bold">Date Issued</h1>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
</div>
</div>
`;
return getHtmlTemplate(themeBody);
}
function getPurpleProfessionalBadge({
studentName,
courseName,
courseDescription,
orgLogoUrl,
orgName,
facilitator,
dateIssued
}) {
const themeBody = `
<img
src="https://assets.cdn.clsrio.com/certificates/purple-professional-badge-background.png"
alt=""
class="absolute top-0 left-0 w-full z-10"
/>
<div class="absolute w-full pl-20 top-10 z-20">
<header class="w-full flex justify-start items-center pt-10 gap-3 text-3xl font-bold">
<img src="${orgLogoUrl}" class="w-24 h-24 rounded-md" />
<h1>${orgName}</h1>
</header>
<div class="text-lg font-bold mt-[7%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-fuchsia-800">${studentName}</h1>
</div>
<div class="text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-5xl mt-3 leading-[3.5rem]">
${courseName}
</h2>
</div>
<div class="flex justify-between w-[70%] mx-auto text-center items-end my-14">
<div>
<p class="font-bold">${dateIssued}</p>
<h1 class="border-t border-black h-2/4 font-bold">Date Issued</h1>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm w-[90%] pb-10">
${courseDescription}
</footer>
</div>
`;
return getHtmlTemplate(themeBody);
}
function getBlueProfessionalBadge({
studentName,
courseName,
courseDescription,
orgLogoUrl,
orgName,
facilitator,
dateIssued
}) {
const themeBody = `
<img
src="https://assets.cdn.clsrio.com/certificates/blue-professional-badge-background.png"
alt=""
class="absolute top-0 left-0 w-full z-10"
/>
<div class="absolute w-full pl-20 top-10 z-20">
<header class="w-full flex justify-start items-center pt-10 gap-3 text-3xl font-bold">
<img src="${orgLogoUrl}" class="w-24 h-24 rounded-md" />
<h1>${orgName}</h1>
</header>
<div class="text-lg font-bold mt-[7%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-blue-800">${studentName}</h1>
</div>
<div class="text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-5xl mt-3 leading-[3.5rem]">
${courseName}
</h2>
</div>
<div class="flex border justify-between w-[70%] mx-auto text-center items-end my-14">
<div>
<p class="font-bold">${dateIssued}</p>
<h1 class="border-t border-black h-2/4 font-bold">Date Issued</h1>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm w-[90%] pb-10">
${courseDescription}
</footer>
</div>
`;
return getHtmlTemplate(themeBody);
}
function getPurpleProfessionalBadgePattern({
studentName,
courseName,
courseDescription,
orgLogoUrl,
orgName,
dateIssued
}) {
const themeBody = `
<img
src="https://assets.cdn.clsrio.com/certificates/purple-lined-background.png"
alt=""
class="absolute top-0 left-0 w-full h-[100vh] z-10"
/>
<div class="absolute z-20 left-[5%] top-16 w-[90%]">
<header class="absolute w-full flex justify-center items-center pt-10 gap-3 text-3xl font-bold">
<img src="${orgLogoUrl}" alt="" class="w-24 h-24 rounded-md" />
<h1>${orgName}</h1>
</header>
<div class="pl-10 text-lg font-bold mt-[18%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-fuchsia-800">${studentName}</h1>
</div>
<div class="pl-10 text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-4xl leading-[2.5rem]">
${courseName}
</h2>
</div>
<div class="flex border justify-between w-[70%] mx-auto text-center items-end my-14">
<div>
<p class="font-bold">${dateIssued}</p>
<h1 class="border-t border-black h-2/4 font-bold">Date Issued</h1>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm pl-10 w-[90%] pb-10">
${courseDescription}
</footer>
</div>
`;
return getHtmlTemplate(themeBody);
}
function getBlueProfessionalBadgePattern({
studentName,
courseName,
courseDescription,
orgLogoUrl,
orgName,
dateIssued
}) {
const themeBody = `
<img
src="https://assets.cdn.clsrio.com/certificates/blue-lined-background.png"
alt=""
class="absolute top-0 left-0 w-full h-[100vh] z-10"
/>
<div class="absolute z-20 left-[5%] top-16 w-[90%]">
<header class="absolute w-full flex justify-center items-center pt-10 gap-3 text-3xl font-bold">
<img src="${orgLogoUrl}" alt="" class="w-24 h-24 rounded-md" />
<h1>${orgName}</h1>
</header>
<div class="pl-10 text-lg font-bold mt-[18%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-blue-800">${studentName}</h1>
</div>
<div class="pl-10 text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-4xl leading-[2.5rem]">
${courseName}
</h2>
</div>
<div class="flex border justify-between w-[70%] mx-auto text-center items-end my-14">
<div>
<p class="font-bold">${dateIssued}</p>
<h1 class="border-t border-black h-2/4 font-bold">Date Issued</h1>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm pl-10 w-[90%] pb-10">
${courseDescription}
</footer>
</div>
`;
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
};
+118
View File
@@ -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<Buffer> => {
const html = generateCertificateHtml(data);
return getPdfBuffer(html);
};
function generateCertificateHtml(data: TCertificateDownload): string {
const { studentName, courseName, courseDescription, orgLogoUrl, orgName, facilitator } = data;
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Certificate of Completion</title>
<style>
body {
font-family: 'Arial', sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.certificate {
width: 1123px;
height: 794px;
margin: 0 auto;
background-color: white;
position: relative;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
.header {
text-align: center;
padding: 20px;
}
.logo {
max-width: 200px;
margin-bottom: 20px;
}
.title {
font-size: 36px;
color: #333;
margin-bottom: 10px;
}
.content {
text-align: center;
padding: 40px;
}
.student-name {
font-size: 48px;
color: #2c3e50;
margin-bottom: 20px;
}
.course-name {
font-size: 24px;
color: #34495e;
margin-bottom: 10px;
}
.description {
font-size: 18px;
color: #7f8c8d;
margin-bottom: 30px;
}
.footer {
position: absolute;
bottom: 40px;
width: 100%;
text-align: center;
}
.signature {
margin-top: 20px;
}
.date {
margin-top: 10px;
color: #95a5a6;
}
</style>
</head>
<body>
<div class="certificate">
<div class="header">
<img src="${orgLogoUrl}" alt="${orgName} Logo" class="logo">
<h1 class="title">Certificate of Completion</h1>
</div>
<div class="content">
<h2 class="student-name">${studentName}</h2>
<p class="course-name">${courseName}</p>
<p class="description">${courseDescription}</p>
</div>
<div class="footer">
<div class="signature">
<p>${facilitator || 'Course Facilitator'}</p>
<p>${orgName}</p>
</div>
<p class="date">${new Date().toLocaleDateString()}</p>
</div>
</div>
</body>
</html>
`;
}
@@ -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 `
<!DOCTYPE html>
<html lang="en">
@@ -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 `
<div
class="bg-[${
orgTheme ? orgTheme : '#0030FF'
}] w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
class="bg-[${orgTheme ? orgTheme : '#0030FF'}] w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
<h3
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">
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}
</h3>
<h1 class="text-white m-0">${lessonTitle}</h1>
@@ -140,83 +137,34 @@ function getLessonBody(
`;
}
function getCourseBody({ courseTitle, orgName, orgTheme, lessons }) {
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">
<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>
<h4 class="text-md text-2xl font-light text-black mt-5 ">${orgName}</h4>
</header>
<div class="root mx-auto mt-5 prose break-after-page">
${lessons.map((lesson) => getLessonBody(lesson, orgName, orgTheme))}
${lessons.map((lesson) => getLessonBody(lesson, orgName, orgTheme)).join('')}
</div>
`;
}
async function generateSinglePdfFromHtml(htmlContent, courseTitle, orgTheme) {
async function generateSinglePdfFromHtml(
htmlContent: string,
courseTitle: string,
orgTheme: string
): Promise<Buffer> {
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:
// "<div><div class='pageNumber'></div> <div>/</div><div class='totalPages'></div></div>",
// footerTemplate: `
// <div class="footer" style="position: relative; display: flex; text-align: right; margin-right: 5%; font-size: 8px; width: 297mm; padding-top: 30px;">
// <div style="position: absolute; right: 50%; bottom: 3%;"><span class="pageNumber"></span>/<span class="totalPages"></span></div>
// <div style="position: absolute; right: 0; bottom: 3%;">${courseTitle} | Powered by <a href="https://app.classroomio.com" style="text-decoration: underline;">ClassroomIO</a></div>
// </div>
// `,
// 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<Buffer> {
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
};
-32
View File
@@ -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 };
+35
View File
@@ -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<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;
}
};
@@ -1,3 +1,3 @@
module.exports = () => {
export default function genUniqueId(): string {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
};
}
@@ -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 `
<!DOCTYPE html>
<html lang="en">
@@ -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:
// "<div><div class='pageNumber'></div> <div>/</div><div class='totalPages'></div></div>",
// footerTemplate: `<div class="footer" style="text-align: right; margin-right: 5%; font-size: 8px; width: 297mm; padding-top: 30px;">
// ${courseTitle} | Powered by <a href="https://app.classroomio.com" style="color: blue; text-decoration: underline;">ClassroomIO</a>
// </div>`,
// 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<Buffer> {
throw 'Download disabled';
}
async function generateLessonPdf(params) {
// Generate the lesson boddy
export async function generateLessonPdf(params: TLessonDownloadContent): Promise<Buffer> {
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:
// "<div><div class='pageNumber'></div> <div>/</div><div class='totalPages'></div></div>",
// footerTemplate:
// '<div style="text-align: right;width: 297mm;font-size: 8px;"><span style="margin-right: 1cm"><span class="pageNumber"></span> of <span class="totalPages"></span></span></div>',
// margin: {
// top: '1cm',
// bottom: '1cm',
// },
// });
}
module.exports = {
generateLessonPdf
};
@@ -1,4 +1,26 @@
const withEmailTemplate = (content) =>
interface FromData {
name: string;
email: string;
}
// format: "ClassroomIO Developers (via ClassroomIO.com)" <[email protected]>
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 =>
`<!DOCTYPE html>
<html>
@@ -189,4 +211,3 @@ const withEmailTemplate = (content) =>
</html>
`;
module.exports = { withEmailTemplate };
-39
View File
@@ -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
};
+75
View File
@@ -0,0 +1,75 @@
import puppeteer, { type PDFOptions } from 'puppeteer';
export interface PdfParams extends Partial<PDFOptions> {
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');
}
};
+56
View File
@@ -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<typeof getSignedUrl>;
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' };
}
}
-30
View File
@@ -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
};
+36
View File
@@ -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;
}
-17
View File
@@ -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;
+24
View File
@@ -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}`;
}