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
+2 -1
View File
@@ -1,3 +1,4 @@
node_modules node_modules
.env .env
.DS_Store .DS_Store
dist
+4 -1
View File
@@ -12,6 +12,7 @@ ARG SMTP_HOST
ARG SMTP_USER ARG SMTP_USER
ARG SMTP_PASSWORD ARG SMTP_PASSWORD
ARG SMTP_PORT ARG SMTP_PORT
ARG SMTP_SENDER
RUN mkdir /app RUN mkdir /app
WORKDIR /app WORKDIR /app
@@ -63,6 +64,8 @@ ENV NODE_ENV=production \
SMTP_PORT=$SMTP_PORT \ SMTP_PORT=$SMTP_PORT \
PORT=3002 PORT=3002
RUN pnpm build
EXPOSE 3002 EXPOSE 3002
CMD ["node", "server.js"] CMD ["pnpm", "start"]
+38 -18
View File
@@ -1,44 +1,64 @@
{ {
"name": "backend", "name": "backend",
"version": "0.0.1", "version": "0.0.1",
"description": "Express Server for long processing", "description": "ClassroomIO Backend for long processes",
"main": "server.js", "main": "dist/index.js",
"type": "module",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "build": "tsc && tsc-alias",
"start": "node server.js", "dev": "tsx watch src/index.ts",
"dev": "nodemon server.js" "start": "node dist/index.js",
"test": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint . --ext .ts",
"format": "prettier --write \"src/**/*.ts\""
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/rotimi-best/classroomio.git" "url": "git+https://github.com/classroomio/classroomio.git"
}, },
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"bugs": { "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": { "dependencies": {
"@aws-sdk/client-s3": "^3.388.0", "@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/node": "^7.110.1",
"@sentry/profiling-node": "^7.110.1", "@sentry/profiling-node": "^7.110.1",
"@supabase/supabase-js": "^2.32.0", "@supabase/supabase-js": "^2.32.0",
"@types/nodemailer": "^6.4.11",
"axios": "^1.4.0", "axios": "^1.4.0",
"body-parser": "^1.20.2",
"bull": "^4.11.3",
"cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.17.1", "hono": "^4.0.5",
"express-rate-limit": "^6.9.0", "hono-rate-limiter": "^0.4.2",
"form-data": "^4.0.0",
"katex": "^0.16.10", "katex": "^0.16.10",
"marked": "^7.0.3", "marked": "^7.0.3",
"multer": "^1.4.3", "nanoid": "^5.1.5",
"nodemailer": "^6.9.5", "nodemailer": "^6.9.5",
"nodemon": "^3.0.1", "puppeteer": "^24.10.0",
"zeptomail": "^6.0.0", "zeptomail": "^6.0.0",
"zod": "^3.21.4" "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"
} }
} }
-75
View File
@@ -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' });
});
+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'); import type { TCourseDownloadContent } from '$src/types/course';
const { marked } = require('marked'); import { marked } from 'marked';
// const puppeteer = require('puppeteer');
function getHtmlTemplate(body) { function getHtmlTemplate(body: string): string {
return ` return `
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -71,22 +70,20 @@ function getHtmlTemplate(body) {
`; `;
} }
type TLessonBody = TCourseDownloadContent['lessons'][0];
function getLessonBody( function getLessonBody(
{ lessonTitle, lessonNumber, lessonNote, slideUrl, video }, { lessonTitle, lessonNumber, lessonNote, slideUrl, video }: TLessonBody,
orgName, orgName: string,
orgTheme orgTheme: string
) { ): string {
const noteHtml = marked.parse(lessonNote); const noteHtml = marked.parse(lessonNote);
const showExtraResources = slideUrl || (video && video.length > 0); const showExtraResources = slideUrl || (video && video.length > 0);
return ` return `
<div <div
class="bg-[${ class="bg-[${orgTheme ? orgTheme : '#0030FF'}] w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
orgTheme ? orgTheme : '#0030FF'
}] w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
<h3 <h3
class="my-0 rounded-full w-10 text-center mx-auto py-1 text-[${ 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">
orgTheme ? orgTheme : '#0030FF'
}] bg-white text-md font-bold tracking-tighter">
${lessonNumber} ${lessonNumber}
</h3> </h3>
<h1 class="text-white m-0">${lessonTitle}</h1> <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 ` return `
<header class="flex justify-center items-center flex-col border-[${ <header class="flex justify-center items-center flex-col border-[${orgTheme ? orgTheme : '#0030FF'}] border-[40px] mx-3 h-[100vh] m-0">
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> <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> <h4 class="text-md text-2xl font-light text-black mt-5 ">${orgName}</h4>
</header> </header>
<div class="root mx-auto mt-5 prose break-after-page"> <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> </div>
`; `;
} }
async function generateSinglePdfFromHtml(htmlContent, courseTitle, orgTheme) { async function generateSinglePdfFromHtml(
htmlContent: string,
courseTitle: string,
orgTheme: string
): Promise<Buffer> {
throw 'Download disabled'; 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) { export async function generateCoursePdf(params: TCourseDownloadContent): Promise<Buffer> {
// Generate the course boddy
const body = getCourseBody(params); const body = getCourseBody(params);
const html = getHtmlTemplate(body); const html = getHtmlTemplate(body);
console.log(html); 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); return Date.now().toString(36) + Math.random().toString(36).substr(2);
}; }
@@ -1,8 +1,7 @@
// const { getPdfBuffer } = require('./puppeteer'); import type { TLessonDownloadContent } from '$src/types/course/lesson';
const { marked } = require('marked'); import { marked } from 'marked';
// const puppeteer = require('puppeteer');
function getHtmlTemplate(body) { function getHtmlTemplate(body: string): string {
return ` return `
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <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 noteHtml = marked.parse(note);
const showExtraResources = slideUrl || (video && video.length > 0); const showExtraResources = slideUrl || (video && video.length > 0);
return ` return `
@@ -139,83 +146,16 @@ function getLessonBody({ title, number, orgName, note, slideUrl, video, courseTi
`; `;
} }
async function generateSinglePdfFromHtml(htmlContent, courseTitle) { async function generateSinglePdfFromHtml(
// const args = [ htmlContent: string,
// '--disable-web-security', courseTitle: string
// '--no-sandbox', ): Promise<Buffer> {
// '--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);
throw 'Download disabled'; throw 'Download disabled';
} }
async function generateLessonPdf(params) { export async function generateLessonPdf(params: TLessonDownloadContent): Promise<Buffer> {
// Generate the lesson boddy
const body = getLessonBody(params); const body = getLessonBody(params);
const html = getHtmlTemplate(body); const html = getHtmlTemplate(body);
console.log(html); console.log(html);
return await generateSinglePdfFromHtml(html, params.courseTitle); 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> `<!DOCTYPE html>
<html> <html>
@@ -189,4 +211,3 @@ const withEmailTemplate = (content) =>
</html> </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}`;
}
+26
View File
@@ -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
}
}
View File
+13
View File
@@ -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/']
}
}
});
@@ -13,6 +13,7 @@
import { fetchProfileCourseProgress } from '$lib/utils/services/courses'; import { fetchProfileCourseProgress } from '$lib/utils/services/courses';
import type { ProfileCourseProgress } from '$lib/utils/types'; import type { ProfileCourseProgress } from '$lib/utils/types';
import { snackbar } from '$lib/components/Snackbar/store'; import { snackbar } from '$lib/components/Snackbar/store';
import { apiClient } from '$lib/utils/services/api';
let isLoading = false; let isLoading = false;
let isCourseComplete = false; let isCourseComplete = false;
@@ -23,24 +24,22 @@
isLoading = true; isLoading = true;
try { try {
const response = await fetch(env.PUBLIC_SERVER_URL + '/downloadCertificate', { const response = await apiClient.post(
method: 'POST', '/course/download/certificate',
headers: { {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
theme: `${$course.certificate_theme}`, theme: `${$course.certificate_theme}`,
studentName: `${$profile.fullname}`, studentName: `${$profile.fullname}`,
courseName: `${$course.title}`, courseName: `${$course.title}`,
courseDescription: `${$course.description}`, courseDescription: `${$course.description}`,
orgLogoUrl: `${$currentOrg.avatar_url}`, orgLogoUrl: `${$currentOrg.avatar_url}`,
orgName: `${$currentOrg.name}` orgName: `${$currentOrg.name}`
}) },
}); {
const data = await response.blob(); responseType: 'blob'
console.log(data); }
const file = new Blob([data], { type: 'application/pdf' }); );
const file = new Blob([response.data], { type: 'application/pdf' });
const fileURL = URL.createObjectURL(file); const fileURL = URL.createObjectURL(file);
let a = document.createElement('a'); let a = document.createElement('a');
@@ -85,12 +84,12 @@
</script> </script>
<Box> <Box>
<div class="flex flex-col items-center justify-center w-max h-full gap-5"> <div class="flex h-full w-max flex-col items-center justify-center gap-5">
<img src="/images/student-certificate-preview.png" alt="Certificate" class="max-w-[218px]" /> <img src="/images/student-certificate-preview.png" alt="Certificate" class="max-w-[218px]" />
<p class="text-xl font-normal text-center"> <p class="text-center text-xl font-normal">
{$t(title)} {$t(title)}
</p> </p>
<p class="text-sm font-normal text-center max-w-md"> <p class="max-w-md text-center text-sm font-normal">
{$t(subtitle)} {$t(subtitle)}
</p> </p>
<PrimaryButton <PrimaryButton
@@ -1,4 +1,5 @@
<script> <script lang="ts">
import type { LessonVideoType } from '$lib/utils/types';
import { isLessonDirty, lesson } from '$lib/components/Course/components/Lesson/store/lessons'; import { isLessonDirty, lesson } from '$lib/components/Course/components/Lesson/store/lessons';
import TextField from '$lib/components/Form/TextField.svelte'; import TextField from '$lib/components/Form/TextField.svelte';
import IconButton from '$lib/components/IconButton/index.svelte'; import IconButton from '$lib/components/IconButton/index.svelte';
@@ -26,7 +27,7 @@
$lesson.materials.videos = [ $lesson.materials.videos = [
...existingLinks, ...existingLinks,
...validLinks.map((link = '') => ({ ...validLinks.map((link = '') => ({
type: 'generic', type: 'generic' as LessonVideoType,
link, link,
metadata: {} metadata: {}
})) }))
@@ -1,6 +1,6 @@
<script> <script>
import YoutubeVideo from './YoutubeVideo.svelte'; import YoutubeVideo from './YoutubeVideo.svelte';
import LocalVideo from './LocalVideo.svelte'; import UploadVideo from './UploadVideo.svelte';
import EmbedLink from './EmbedLink.svelte'; import EmbedLink from './EmbedLink.svelte';
import * as CONSTANTS from './constants'; import * as CONSTANTS from './constants';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
@@ -9,25 +9,25 @@
export let lessonId = ''; export let lessonId = '';
let tabs = CONSTANTS.videoTabs; const tabs = CONSTANTS.videoTabs;
let currentTab = tabs[0].value; let currentTab = tabs[0].value;
const onChange = (tab) => () => (currentTab = tab); const onChange = (tab) => () => (currentTab = tab);
</script> </script>
<section class="w-full h-full flex flex-col md:flex-row items-start gap-3"> <section class="flex h-full w-full flex-col items-start gap-3 md:flex-row">
<div class="flex flex-row md:flex-col gap-2 items-center"> <div class="flex flex-row items-center gap-2 md:flex-col">
<p class="text-sm font-normal text-start text-[#4F4B4B] dark:text-[#b0a9a9] mb-3 w-full"> <p class="mb-3 w-full text-start text-sm font-normal text-[#4F4B4B] dark:text-[#b0a9a9]">
{$t('course.navItem.lessons.materials.tabs.video.add_video.add_by')} {$t('course.navItem.lessons.materials.tabs.video.add_video.add_by')}
</p> </p>
{#each tabs as item (item.value)} {#each tabs as item (item.value)}
<button <button
on:click={onChange(item.value)} on:click={onChange(item.value)}
class={`w-full px-4 py-3 my-1 border ${ class={`my-1 w-full border px-4 py-3 ${
currentTab === item.value currentTab === item.value
? 'border border-[#0233BD] bg-[#F5F8FE] dark:text-black' ? 'border border-[#0233BD] bg-[#F5F8FE] dark:text-black'
: 'border border-gray-200 dark:border-neutral-600' : 'border border-gray-200 dark:border-neutral-600'
} rounded-md cursor-pointer flex flex-row items-center justify-start gap-2 whitespace-nowrap`} } flex cursor-pointer flex-row items-center justify-start gap-2 whitespace-nowrap rounded-md`}
> >
{#if $isFreePlan && item.value === 3} {#if $isFreePlan && item.value === 3}
<FlashFilled size={20} class="text-blue-700" /> <FlashFilled size={20} class="text-blue-700" />
@@ -42,13 +42,13 @@
</button> </button>
{/each} {/each}
</div> </div>
<main class="w-full h-full"> <main class="h-full w-full">
{#if currentTab === 1} {#if currentTab === 1}
<YoutubeVideo /> <YoutubeVideo />
{:else if currentTab === 2} {:else if currentTab === 2}
<EmbedLink /> <EmbedLink />
{:else} {:else}
<LocalVideo {lessonId} /> <UploadVideo {lessonId} />
{/if} {/if}
</main> </main>
</section> </section>
@@ -1,69 +1,96 @@
<script lang="ts"> <script lang="ts">
import axios from 'axios';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import axios from 'axios';
import { import {
lesson, lesson,
deleteLessonVideo, deleteLessonVideo,
uploadCourseVideoStore uploadCourseVideoStore
} from '$lib/components/Course/components/Lesson/store/lessons'; } from '$lib/components/Course/components/Lesson/store/lessons';
import { onMount, onDestroy } from 'svelte'; import { type ComponentProps } from 'svelte';
import { ProgressBar } from 'carbon-components-svelte'; import { ProgressBar } from 'carbon-components-svelte';
import { supabase } from '$lib/utils/functions/supabase';
import { isFreePlan } from '$lib/utils/store/org'; import { isFreePlan } from '$lib/utils/store/org';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte'; import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import { apiClient } from '$lib/utils/services/api';
let value = 0;
let max = 100;
let status = 'active';
let fileSize;
let isDisabled = false;
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
export let lessonId = ''; export let lessonId = '';
let value = 0;
let max = 100;
let status: ComponentProps<ProgressBar>['status'] = 'active';
let fileSize;
let isDisabled = false;
let formRes; let formRes;
let isLoaded = false; let isLoaded = false;
let fileInput; let fileInput;
let submit; let submit;
let uploadedFileUrl = ''; let fileName = '';
let isLoading = false;
let prevProgress = 0;
let uploadChannel;
function isVideoAdded(link) { async function onUpload() {
return $lesson.materials?.videos?.find((v) => v.link === link);
}
async function onUpload(e) {
isLoading = true;
if (!fileInput) return; if (!fileInput) return;
const formData = new FormData(); $uploadCourseVideoStore.isUploading = true;
const videoFile = fileInput.files[0]; const videoFile = fileInput.files[0];
formData.append('videoFile', videoFile);
console.log({ size: videoFile?.size });
fileSize = videoFile?.size / (1024 * 1024); fileSize = videoFile?.size / (1024 * 1024);
try { try {
const response = await axios({ const uploadPresignResponse = await apiClient.post('/course/presign/upload', {
method: 'POST', fileName: videoFile.name,
url: env.PUBLIC_SERVER_URL + '/uploadVideo?lessonId=' + lessonId, fileType: videoFile.type
data: formData, });
const presignedUrl = uploadPresignResponse.data.url;
const uploadResponse = await axios.put(presignedUrl, videoFile, {
headers: {
'Content-Type': videoFile.type
},
maxContentLength: Infinity, maxContentLength: Infinity,
maxBodyLength: Infinity, maxBodyLength: Infinity,
headers: {
'Content-Type': 'multipart/form-data; boundary=MyBoundary'
},
onUploadProgress: (progressEvent) => { onUploadProgress: (progressEvent) => {
value = Math.round((progressEvent.loaded * 100) / progressEvent?.total / 2); value = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 1) / 2);
} }
}); });
formRes = response.data; const downloadPresignedResponse = await apiClient.post('/course/presign/download', {
console.log('Upload res', formRes); keys: [uploadPresignResponse.data.fileKey]
isLoading = false; });
if (!downloadPresignedResponse.data.success) {
formRes = {
type: 'INTERNAL_ERROR',
status: 500,
message: downloadPresignedResponse.data.message
};
return;
}
const fileKey = uploadPresignResponse.data.fileKey;
const presignedUrls = downloadPresignedResponse.data.urls;
formRes = {
url: presignedUrls[fileKey],
fileKey: uploadPresignResponse.data.fileKey,
status: uploadResponse.status
};
console.log('formRes', formRes);
$lesson.materials.videos = [
...$lesson.materials.videos,
{
type: 'upload',
link: formRes.url,
key: formRes?.fileKey
}
];
console.log('Upload res', uploadResponse, 'formRes', formRes);
$uploadCourseVideoStore.isUploading = false;
isLoaded = false; isLoaded = false;
} catch (err: any) { } catch (err: any) {
console.error('Error uploading video', err, '\n\n', err.response); console.error('Error uploading video', err, '\n\n', err.response);
@@ -76,50 +103,20 @@
isLoaded = true; isLoaded = true;
} }
async function isDoneUploading(response) {
uploadedFileUrl = response?.success && response?.url;
if (uploadedFileUrl && !isVideoAdded(uploadedFileUrl)) {
$lesson.materials.videos = [
...$lesson.materials.videos,
{
type: 'muse',
link: uploadedFileUrl,
metadata: response?.metadata || {}
}
];
}
}
function tryAgain() { function tryAgain() {
formRes = null; formRes = null;
isLoaded = false; isLoaded = false;
isLoading = false; $uploadCourseVideoStore.isUploading = false;
value = 0;
} }
onMount(async () => {
uploadChannel = supabase
.channel('upload-progress')
.on('broadcast', { event: lessonId }, (payload) => {
console.log('The progress of the upload', payload);
value = value + payload?.payload - prevProgress;
prevProgress = payload.payload;
})
.subscribe();
});
onDestroy(() => {
supabase.removeChannel(uploadChannel);
});
$: helperText = value + '% of ' + Math.round(fileSize) + 'MB'; $: helperText = value + '% of ' + Math.round(fileSize) + 'MB';
$: if (value === max) { $: if (value === max) {
helperText = 'Done'; helperText = 'Done';
status = 'finished'; status = 'finished';
} }
$: isDoneUploading(formRes); $: isDisabled = $uploadCourseVideoStore.isUploading || !env.PUBLIC_SERVER_URL || $isFreePlan;
$: isDisabled = isLoading || !env.PUBLIC_SERVER_URL || $isFreePlan;
</script> </script>
<UpgradeBanner className="mb-3" onClick={() => ($uploadCourseVideoStore.isModalOpen = false)}> <UpgradeBanner className="mb-3" onClick={() => ($uploadCourseVideoStore.isModalOpen = false)}>
@@ -129,16 +126,16 @@
{#if !isLoaded} {#if !isLoaded}
<button <button
type="button" type="button"
on:click={() => (fileInput && !isLoading ? fileInput.click() : null)} on:click={() => (fileInput && !$uploadCourseVideoStore.isUploading ? fileInput.click() : null)}
class="h-full w-full {isDisabled && 'hover:cursor-not-allowed opacity-50'}" class="h-full w-full {isDisabled && 'opacity-50 hover:cursor-not-allowed'}"
disabled={isDisabled} disabled={isDisabled}
> >
<form <form
class="border-primary-300 flex h-full w-full flex-col items-center justify-center rounded-xl border border-dashed" class="border-primary-300 flex h-full w-full flex-col items-center justify-center rounded-xl border border-dashed"
on:submit|preventDefault={onUpload} on:submit|preventDefault={onUpload}
> >
{#if isLoading} {#if $uploadCourseVideoStore.isUploading}
<div class="flex flex-col gap-5 max-w-[500px] w-[60%] justify-center"> <div class="flex w-[60%] max-w-[500px] flex-col justify-center gap-5">
<p class="mt-5 text-center"> <p class="mt-5 text-center">
{$t('course.navItem.lessons.materials.tabs.video.add_video.uploading')} {$t('course.navItem.lessons.materials.tabs.video.add_video.uploading')}
</p> </p>
@@ -186,7 +183,7 @@
onClick={tryAgain} onClick={tryAgain}
/> />
</div> </div>
{:else if !formRes?.success} {:else if formRes?.status !== 200}
<div class="flex h-full w-full flex-col items-center justify-center rounded-xl"> <div class="flex h-full w-full flex-col items-center justify-center rounded-xl">
<img src="/video-upload-error.svg" alt="upload error" /> <img src="/video-upload-error.svg" alt="upload error" />
<span class="pb-2 pt-3"> <span class="pb-2 pt-3">
@@ -210,13 +207,11 @@
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<div class="overflow-hidden rounded-sm"> <div class="overflow-hidden rounded-sm">
<video class="w-[200px]"> <video class="w-[200px]">
<source src={uploadedFileUrl} type="video/mp4" /> <source src={formRes.url} type="video/mp4" />
<!-- <source src="/path/to/video.webm" type="video/webm" /> -->
<!-- Captions are optional -->
<track kind="captions" /> <track kind="captions" />
</video> </video>
</div> </div>
<p>{fileInput?.files?.[0]?.name || uploadedFileUrl}</p> <p>{fileInput?.files?.[0]?.name || fileName}</p>
</span> </span>
<button on:click={deleteLessonVideo}> <button on:click={deleteLessonVideo}>
<img src="/delete-video.svg" alt="deletevideo" class="dark:invert" /> <img src="/delete-video.svg" alt="deletevideo" class="dark:invert" />
@@ -1,4 +1,4 @@
<script> <script lang="ts">
import { CopyButton, Tag } from 'carbon-components-svelte'; import { CopyButton, Tag } from 'carbon-components-svelte';
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte'; import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
import IconButton from '$lib/components/IconButton/index.svelte'; import IconButton from '$lib/components/IconButton/index.svelte';
@@ -6,6 +6,7 @@
import { lesson, isLessonDirty } from '$lib/components/Course/components/Lesson/store/lessons'; import { lesson, isLessonDirty } from '$lib/components/Course/components/Lesson/store/lessons';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import type { LessonVideoType } from '$lib/utils/types';
let youtubeLinks = ''; let youtubeLinks = '';
let error = ''; let error = '';
@@ -26,7 +27,7 @@
$lesson.materials.videos = [ $lesson.materials.videos = [
...existingLinks, ...existingLinks,
...validLinks.map((link = '') => ({ ...validLinks.map((link = '') => ({
type: 'youtube', type: 'youtube' as LessonVideoType,
link, link,
metadata: {} metadata: {}
})) }))
@@ -48,7 +49,7 @@
} }
</script> </script>
<div class="w-full flex items-{error ? 'center' : 'end'} justify-between gap-5"> <div class="flex w-full items-{error ? 'center' : 'end'} justify-between gap-5">
<TextField <TextField
label={$t('course.navItem.lessons.materials.tabs.video.add_video.youtube_link')} label={$t('course.navItem.lessons.materials.tabs.video.add_video.youtube_link')}
bind:value={youtubeLinks} bind:value={youtubeLinks}
@@ -1,29 +1,41 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { formatYoutubeVideo } from '$lib/utils/functions/formatYoutubeVideo'; import { formatYoutubeVideo } from '$lib/utils/functions/formatYoutubeVideo';
import { lesson } from '../../store/lessons'; import { lesson } from '../../store/lessons';
let errors = {}; let errors: Record<string, string> = {};
let player = null; let videoElements: (HTMLVideoElement | null)[] = [];
function initPlyr(_player: any, _video: string | undefined) { function initPlyr() {
if (!_player) return; if (typeof (window as any).Plyr === 'undefined') {
console.warn('Plyr is not defined. Make sure it is properly imported.');
const players = Array.from(document.querySelectorAll('.plyr-video-trigger')).map((p) => { return;
// @ts-ignore }
return new Plyr(p); const players = Array.from(document.querySelectorAll('.plyr-video-trigger')).map(
}); (p) => new (window as any).Plyr(p as HTMLElement)
);
// @ts-ignore if (typeof window !== 'undefined') {
window.players = players; (window as any).players = players;
}
} }
$: initPlyr(player, $lesson.materials.videos); $: {
videoElements = videoElements.slice(0, $lesson.materials.videos.length);
videoElements = [
...videoElements,
...Array($lesson.materials.videos.length - videoElements.length).fill(null)
];
}
onMount(() => {
initPlyr();
});
</script> </script>
{#if $lesson.materials.videos.length} {#if $lesson.materials.videos.length}
<div class="w-full"> <div class="w-full">
{#each $lesson.materials.videos as video} {#each $lesson.materials.videos as video, index}
<div class="w-full overflow-hidden mb-5"> <div class="mb-5 w-full overflow-hidden">
{#key video.link} {#key video.link}
<div class="mb-5"> <div class="mb-5">
{#if video.type === 'youtube'} {#if video.type === 'youtube'}
@@ -58,7 +70,12 @@
/> />
</div> </div>
{:else} {:else}
<video bind:this={player} class="plyr-video-trigger" playsinline controls> <video
bind:this={videoElements[index]}
class="plyr-video-trigger h-full w-full"
playsinline
controls
>
<source src={video.link} type="video/mp4" /> <source src={video.link} type="video/mp4" />
<track kind="captions" /> <track kind="captions" />
</video> </video>
@@ -59,9 +59,7 @@
let timeoutId: NodeJS.Timeout; let timeoutId: NodeJS.Timeout;
let tabs = CONSTANTS.tabs; let tabs = CONSTANTS.tabs;
let currentTab = tabs[0].value; let currentTab = tabs[0].value;
let errors: { let errors: Record<string, string> = {};
video: string;
};
let editorWindowRef: Window; let editorWindowRef: Window;
let aiButtonRef: HTMLDivElement; let aiButtonRef: HTMLDivElement;
let openPopover = false; let openPopover = false;
@@ -268,7 +266,10 @@
} }
const onClose = () => { const onClose = () => {
if ($uploadCourseVideoStore.isUploading) return;
$uploadCourseVideoStore.isModalOpen = false; $uploadCourseVideoStore.isModalOpen = false;
autoSave($lesson.materials, $lessonByTranslation[lessonId], $isLoading, lessonId);
}; };
function getComponentOrder(tabs = CONSTANTS.tabs) { function getComponentOrder(tabs = CONSTANTS.tabs) {
@@ -1,19 +1,21 @@
import { writable } from 'svelte/store'; import type { Course, Lesson, LessonComment, LessonPage, LessonSection } from '$lib/utils/types';
import type { Writable, Updater } from 'svelte/store'; import type { Updater, Writable } from 'svelte/store';
import { import {
createLesson, createLesson,
updateLesson,
deleteLesson,
createLessonSection, createLessonSection,
updateLessonSection, deleteLesson,
deleteLessonSection deleteLessonSection,
updateLesson,
updateLessonSection
} from '$lib/utils/services/courses'; } from '$lib/utils/services/courses';
import type { Lesson, Course, LessonPage, LessonComment, LessonSection } from '$lib/utils/types';
import { LOCALE } from '$lib/utils/types'; import { LOCALE } from '$lib/utils/types';
import { snackbar } from '$lib/components/Snackbar/store';
import { lessonValidation } from '$lib/utils/functions/validator'; import { lessonValidation } from '$lib/utils/functions/validator';
import { snackbar } from '$lib/components/Snackbar/store';
import { writable } from 'svelte/store';
export const uploadCourseVideoStore = writable({ export const uploadCourseVideoStore = writable({
isUploading: false,
isModalOpen: false isModalOpen: false
}); });
@@ -16,7 +16,6 @@
</script> </script>
<div <div
role="button"
tabindex="0" tabindex="0"
class="overflow-y-auto {isPageNavHidden class="overflow-y-auto {isPageNavHidden
? 'h-[calc(100vh-65px)] lg:h-[calc(100vh-127px)]' ? 'h-[calc(100vh-65px)] lg:h-[calc(100vh-127px)]'
@@ -1,4 +1,4 @@
export function formatYoutubeVideo(url: string, errors: { video: string }) { export function formatYoutubeVideo(url: string, errors: Record<string, string>) {
console.log(`url`, url); console.log(`url`, url);
const prefix = 'https://www.youtube.com/embed'; const prefix = 'https://www.youtube.com/embed';
@@ -89,7 +89,7 @@ export const addMathPlugin = (tinymce: any) => {
if (srcMatch) { if (srcMatch) {
// 2. Point the formula to our server // 2. Point the formula to our server
const customAPI = env.PUBLIC_SERVER_URL + '/katex'; const customAPI = env.PUBLIC_SERVER_URL + '/course/katex';
const url = srcMatch[1]?.replaceAll('https://latex.codecogs.com/svg.image', customAPI); const url = srcMatch[1]?.replaceAll('https://latex.codecogs.com/svg.image', customAPI);
// 3. Fetch the HTML from the server // 3. Fetch the HTML from the server
@@ -0,0 +1,19 @@
import axios from 'axios';
import { env } from '$env/dynamic/public';
import { getAccessToken } from '$lib/utils/functions/supabase';
export const apiClient = axios.create({
baseURL: `${env.PUBLIC_SERVER_URL}`,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
});
apiClient.interceptors.request.use(async (config) => {
const token = await getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
@@ -1,8 +1,3 @@
import { QUESTION_TYPE } from '$lib/components/Question/constants';
import { STATUS } from '$lib/utils/constants/course';
import { isUUID } from '$lib/utils/functions/isUUID';
import { supabase } from '$lib/utils/functions/supabase';
import { isOrgAdmin } from '$lib/utils/store/org';
import type { import type {
Course, Course,
Exercise, Exercise,
@@ -15,12 +10,19 @@ import type {
ProfileCourseProgress ProfileCourseProgress
} from '$lib/utils/types'; } from '$lib/utils/types';
import type { PostgrestError, PostgrestSingleResponse } from '@supabase/supabase-js'; import type { PostgrestError, PostgrestSingleResponse } from '@supabase/supabase-js';
import { QUESTION_TYPE } from '$lib/components/Question/constants';
import { STATUS } from '$lib/utils/constants/course';
import { apiClient } from '$lib/utils/services/api';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { isOrgAdmin } from '$lib/utils/store/org';
import { isUUID } from '$lib/utils/functions/isUUID';
import { supabase } from '$lib/utils/functions/supabase';
export async function fetchCourses(profileId, orgId) { export async function fetchCourses(profileId, orgId) {
if (!orgId || !profileId) return; if (!orgId || !profileId) return;
const match = {}; const match: { member_profile_id?: string } = {};
// Filter by profile_id if role isn't admin within organization // Filter by profile_id if role isn't admin within organization
if (!get(isOrgAdmin)) { if (!get(isOrgAdmin)) {
match.member_profile_id = profileId; match.member_profile_id = profileId;
@@ -257,15 +259,13 @@ export async function getMarks(courseId) {
if (!courseId) return; if (!courseId) return;
// Gets courses for a particular organisation where the current logged in user is a groupmember // Gets courses for a particular organisation where the current logged in user is a groupmember
const { data: marks } = await supabase const { data: marks } = await supabase.rpc('get_marks').eq('course_id', courseId);
.rpc('get_marks')
.eq('course_id', courseId);
return { marks }; return { marks };
} }
export function fetchLesson(lessonId: Lesson['id']) { export async function fetchLesson(lessonId: Lesson['id']) {
return supabase const { data, error } = await supabase
.from('lesson') .from('lesson')
.select( .select(
`id, `id,
@@ -282,6 +282,29 @@ export function fetchLesson(lessonId: Lesson['id']) {
) )
.eq('id', lessonId) .eq('id', lessonId)
.single(); .single();
if (data) {
const keys = data.videos.filter((video) => video.type === 'upload').map((video) => video.key);
if (!keys.length) {
return { data, error };
}
try {
const response = await apiClient.post('/course/presign/download', { keys });
data.videos = data.videos.map((video) => {
if (video.type === 'upload') {
video.link = response.data.urls[video.key];
}
return video;
});
} catch (error) {
console.error('Error retrieving videos:', error);
}
}
return { data, error };
} }
export function fetchLesssonLanguageHistory(lessonId: string, locale: string, endRange: number) { export function fetchLesssonLanguageHistory(lessonId: string, locale: string, endRange: number) {
+3 -5
View File
@@ -279,10 +279,7 @@ export interface Group_attendance {
groupmember?: Groupmember; groupmember?: Groupmember;
} }
export enum VideoType { export type LessonVideoType = 'youtube' | 'generic' | 'upload';
youtube,
muse
}
export interface LessonPage { export interface LessonPage {
id?: string | null; id?: string | null;
@@ -296,8 +293,9 @@ export interface LessonPage {
note: string; note: string;
slide_url: string; slide_url: string;
videos: Array<{ videos: Array<{
type: string; type: LessonVideoType;
link: string; link: string;
key?: string;
metadata?: { metadata?: {
svid?: string; svid?: string;
}; };
+29 -28
View File
@@ -1,35 +1,36 @@
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
const sendEmail = async ( const sendEmail = (sFetch: typeof fetch) => {
emailDataArray: { return async (
from?: string; emailDataArray: {
to: string; from?: string;
subject: string; to: string;
content: string; subject: string;
replyTo?: string; content: string;
isPersonalEmail?: boolean; replyTo?: string;
}[] isPersonalEmail?: boolean;
) => { }[]
try { ) => {
const response = await fetch(`${env.PUBLIC_SERVER_URL}/sendEmail`, { try {
method: 'POST', const response = await sFetch(`${env.PUBLIC_SERVER_URL}/mail/send`, {
headers: { method: 'POST',
'Content-Type': 'application/json' headers: {
}, 'Content-Type': 'application/json'
body: JSON.stringify(emailDataArray) },
}); body: JSON.stringify(emailDataArray)
});
if (!response.ok) { if (!response.ok) {
console.log('Failed to send emails'); console.log('Failed to send emails');
return;
}
console.log('Emails sent successfully:', response);
return response;
} catch (error) {
console.error('Error sending emails:', error);
return; return;
} }
};
const responseData = await response.json();
console.log('Emails sent successfully:', responseData);
return responseData;
} catch (error) {
console.error('Error sending emails:', error);
return;
}
}; };
export default sendEmail; export default sendEmail;
@@ -1,5 +1,6 @@
import { Configuration, OpenAIApi } from 'openai-edge'; import { Configuration, OpenAIApi } from 'openai-edge';
import { OpenAIStream, StreamingTextResponse } from 'ai'; import { OpenAIStream, StreamingTextResponse } from 'ai';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
// Create an OpenAI API client (that's edge friendly!) // Create an OpenAI API client (that's edge friendly!)
@@ -1,7 +1,7 @@
import sendEmail from '$mail/sendEmail';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import sendEmail from '$mail/sendEmail';
export async function POST({ request }) { export async function POST({ fetch, request }) {
const { to, content, orgName, exerciseTitle } = await request.json(); const { to, content, orgName, exerciseTitle } = await request.json();
if (!to || !content || !orgName) { if (!to || !content || !orgName) {
@@ -17,7 +17,7 @@ export async function POST({ request }) {
} }
]; ];
await sendEmail(emailData); await sendEmail(fetch)(emailData);
return json({ return json({
success: true, success: true,
@@ -1,11 +1,16 @@
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { getFeedForNotification } from '$lib/utils/services/newsfeed/index'; import { getFeedForNotification } from '$lib/utils/services/newsfeed/index';
import sendEmail from '$mail/sendEmail'; import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import sendEmail from '$mail/sendEmail';
const supabase = getServerSupabase(); const supabase = getServerSupabase();
const sendEmailNotification = async (feedId: string, authorId: string, comment?: string) => { const sendEmailNotification = async (
sFetch: typeof fetch,
feedId: string,
authorId: string,
comment?: string
) => {
const feed = await getFeedForNotification({ const feed = await getFeedForNotification({
supabase, supabase,
feedId, feedId,
@@ -34,7 +39,7 @@ const sendEmailNotification = async (feedId: string, authorId: string, comment?:
} }
]; ];
await sendEmail(emailData); await sendEmail(sFetch)(emailData);
// dont continue // dont continue
return; return;
@@ -62,10 +67,10 @@ const sendEmailNotification = async (feedId: string, authorId: string, comment?:
console.log('Sending emails to all students', feed.courseMembers.length); console.log('Sending emails to all students', feed.courseMembers.length);
// This is the email sending function with a loop // This is the email sending function with a loop
await sendEmail(emailsData); await sendEmail(sFetch)(emailsData);
}; };
export async function POST({ request }) { export async function POST({ fetch, request }) {
const { authorId, feedId, comment } = await request.json(); const { authorId, feedId, comment } = await request.json();
console.log('/POST api/email/course/newsfeed'); console.log('/POST api/email/course/newsfeed');
@@ -73,7 +78,7 @@ export async function POST({ request }) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 }); return json({ success: false, message: 'Missing required fields' }, { status: 400 });
} }
await sendEmailNotification(feedId, authorId, comment); await sendEmailNotification(fetch, feedId, authorId, comment);
// TODO: Support sending to other platforms like telegram and discord // TODO: Support sending to other platforms like telegram and discord
// sendDiscordNotification(); sendTelegramNotification(); // sendDiscordNotification(); sendTelegramNotification();
@@ -4,7 +4,7 @@ import sendEmail from '$mail/sendEmail';
// const supabase = getSupabase(); // const supabase = getSupabase();
export async function POST({ request }) { export async function POST({ fetch, request }) {
const { to, courseName, teacherEmail, studentFullname, orgName } = await request.json(); const { to, courseName, teacherEmail, studentFullname, orgName } = await request.json();
// const accessToken = request.headers.get('Authorization') || ''; // const accessToken = request.headers.get('Authorization') || '';
console.log( console.log(
@@ -48,7 +48,7 @@ export async function POST({ request }) {
} }
]; ];
await sendEmail(emailData); await sendEmail(fetch)(emailData);
return json({ return json({
success: true, success: true,
@@ -1,8 +1,8 @@
import sendEmail from '$mail/sendEmail';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import sendEmail from '$mail/sendEmail';
// Sends email to student when they successfully join a course // Sends email to student when they successfully join a course
export async function POST({ request }) { export async function POST({ fetch, request }) {
const { to, orgName, courseName } = await request.json(); const { to, orgName, courseName } = await request.json();
console.log('/POST api/email/course/student_welcome', to, orgName); console.log('/POST api/email/course/student_welcome', to, orgName);
@@ -25,7 +25,7 @@ export async function POST({ request }) {
` `
} }
]; ];
await sendEmail(emailData); await sendEmail(fetch)(emailData);
return json({ return json({
success: true, success: true,
@@ -1,7 +1,7 @@
import sendEmail from '$mail/sendEmail';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import sendEmail from '$mail/sendEmail';
export async function POST({ request }) { export async function POST({ fetch, request }) {
const { to, content, orgName } = await request.json(); const { to, content, orgName } = await request.json();
if (!to || !content || !orgName) { if (!to || !content || !orgName) {
@@ -17,7 +17,7 @@ export async function POST({ request }) {
} }
]; ];
await sendEmail(emailData); await sendEmail(fetch)(emailData);
return json({ return json({
success: true, success: true,
@@ -1,7 +1,7 @@
import sendEmail from '$mail/sendEmail';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import sendEmail from '$mail/sendEmail';
export async function POST({ request }) { export async function POST({ fetch, request }) {
const { to, courseName, studentEmail, studentFullname } = await request.json(); const { to, courseName, studentEmail, studentFullname } = await request.json();
console.log( console.log(
'/POST api/email/course/teacher_student_buycourse', '/POST api/email/course/teacher_student_buycourse',
@@ -31,7 +31,7 @@ export async function POST({ request }) {
` `
} }
]; ];
await sendEmail(emailData); await sendEmail(fetch)(emailData);
return json({ return json({
success: true, success: true,
@@ -1,7 +1,7 @@
import sendEmail from '$mail/sendEmail';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import sendEmail from '$mail/sendEmail';
export async function POST({ request }) { export async function POST({ fetch, request }) {
const { to, courseName, studentName, studentEmail } = await request.json(); const { to, courseName, studentName, studentEmail } = await request.json();
console.log('/POST api/email/course/teacher_student_joined', to, courseName); console.log('/POST api/email/course/teacher_student_joined', to, courseName);
@@ -23,7 +23,7 @@ export async function POST({ request }) {
} }
]; ];
await sendEmail(emailData); await sendEmail(fetch)(emailData);
return json({ return json({
success: true, success: true,
@@ -1,7 +1,7 @@
import sendEmail from '$mail/sendEmail';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import sendEmail from '$mail/sendEmail';
export async function POST({ request }) { export async function POST({ fetch, request }) {
const { to, name, orgName, courseName, orgSiteName } = await request.json(); const { to, name, orgName, courseName, orgSiteName } = await request.json();
console.log('/POST api/email/course/teacher_welcome', to, name, orgName); console.log('/POST api/email/course/teacher_welcome', to, name, orgName);
@@ -28,7 +28,7 @@ export async function POST({ request }) {
} }
]; ];
await sendEmail(emailData); await sendEmail(fetch)(emailData);
return json({ return json({
success: true, success: true,
@@ -1,8 +1,8 @@
import sendEmail from '$mail/sendEmail';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import sendEmail from '$mail/sendEmail';
// API to send invite to teacher // API to send invite to teacher
export async function POST({ request }) { export async function POST({ fetch, request }) {
const body = await request.json(); const body = await request.json();
const { org, email } = body; const { org, email } = body;
@@ -40,7 +40,7 @@ export async function POST({ request }) {
` `
} }
]; ];
await sendEmail(emailData); await sendEmail(fetch)(emailData);
return json({ return json({
success: true, success: true,
@@ -1,7 +1,7 @@
import sendEmail from '$mail/sendEmail';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import sendEmail from '$mail/sendEmail';
export async function POST({ request }) { export async function POST({ fetch, request }) {
const { to, profileId, orgSiteName, fullname } = await request.json(); const { to, profileId, orgSiteName, fullname } = await request.json();
console.log('/POST api/email/verify_email', to, profileId); console.log('/POST api/email/verify_email', to, profileId);
@@ -36,7 +36,7 @@ export async function POST({ request }) {
} }
]; ];
await sendEmail(emailData); await sendEmail(fetch)(emailData);
return json({ return json({
success: true, success: true,
@@ -1,7 +1,7 @@
import sendEmail from '$mail/sendEmail';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import sendEmail from '$mail/sendEmail';
export async function POST({ request }) { export async function POST({ fetch, request }) {
const { to, name } = await request.json(); const { to, name } = await request.json();
console.log('/POST api/email/welcome', to, name); console.log('/POST api/email/welcome', to, name);
@@ -33,7 +33,7 @@ export async function POST({ request }) {
} }
]; ];
await sendEmail(emailData); await sendEmail(fetch)(emailData);
return json({ return json({
success: true, success: true,
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { env } from '$env/dynamic/public';
import CourseContainer from '$lib/components/CourseContainer/index.svelte'; import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import { import {
checkExercisesComplete, checkExercisesComplete,
@@ -30,8 +29,6 @@
import IconButton from '$lib/components/IconButton/index.svelte'; import IconButton from '$lib/components/IconButton/index.svelte';
import CourseIcon from '$lib/components/Icons/CourseIcon.svelte'; import CourseIcon from '$lib/components/Icons/CourseIcon.svelte';
import { PageBody, PageNav } from '$lib/components/Page'; import { PageBody, PageNav } from '$lib/components/Page';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte'; import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { snackbar } from '$lib/components/Snackbar/store'; import { snackbar } from '$lib/components/Snackbar/store';
import MODES from '$lib/utils/constants/mode'; import MODES from '$lib/utils/constants/mode';
@@ -42,9 +39,9 @@
import { COURSE_VERSION, type Lesson, type LessonCompletion } from '$lib/utils/types'; import { COURSE_VERSION, type Lesson, type LessonCompletion } from '$lib/utils/types';
import { Dropdown } from 'carbon-components-svelte'; import { Dropdown } from 'carbon-components-svelte';
import { ChevronLeft, ChevronRight, Edit, Save } from 'carbon-icons-svelte'; import { ChevronLeft, ChevronRight, Edit, Save } from 'carbon-icons-svelte';
import Download from 'carbon-icons-svelte/lib/Download.svelte';
import OverflowMenuVertical from 'carbon-icons-svelte/lib/OverflowMenuVertical.svelte'; import OverflowMenuVertical from 'carbon-icons-svelte/lib/OverflowMenuVertical.svelte';
import ResultOld from 'carbon-icons-svelte/lib/ResultOld.svelte'; import ResultOld from 'carbon-icons-svelte/lib/ResultOld.svelte';
import { apiClient } from '$lib/utils/services/api';
export let data; export let data;
@@ -163,13 +160,9 @@
const lessonNumber = getLessonOrder(currentLesson.id); const lessonNumber = getLessonOrder(currentLesson.id);
const slideUrl = $lesson.materials.slide_url || ''; const slideUrl = $lesson.materials.slide_url || '';
const response = await fetch(env.PUBLIC_SERVER_URL + '/downloadLesson', { const response = await apiClient.post(
method: 'POST', '/course/lesson/download/pdf',
headers: { {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: currentLesson.title, title: currentLesson.title,
number: lessonNumber, number: lessonNumber,
orgName: $currentOrg.name, orgName: $currentOrg.name,
@@ -177,16 +170,13 @@
slideUrl: slideUrl, slideUrl: slideUrl,
video: lessonVideo, video: lessonVideo,
courseTitle: $course.title courseTitle: $course.title
}) },
}); {
responseType: 'blob'
}
);
if (!response.ok) { const file = new Blob([response.data], { type: 'application/pdf' });
throw new Error(await response.text());
}
const data = await response.blob();
console.log(data);
const file = new Blob([data], { type: 'application/pdf' });
const fileURL = URL.createObjectURL(file); const fileURL = URL.createObjectURL(file);
let a = document.createElement('a'); let a = document.createElement('a');
@@ -386,17 +376,6 @@
<Edit size={24} /> <Edit size={24} />
{/if} {/if}
</IconButton> </IconButton>
{#if $course.metadata.lessonDownload && !!env.PUBLIC_SERVER_URL}
<PrimaryButton
className="mr-"
variant={VARIANTS.OUTLINED}
onClick={downloadLesson}
{isLoading}
>
<Download size={16} />
</PrimaryButton>
{/if}
</div> </div>
<Dropdown items={LANGUAGES} bind:selectedId={$lesson.locale} class="h-full" /> <Dropdown items={LANGUAGES} bind:selectedId={$lesson.locale} class="h-full" />
@@ -29,6 +29,7 @@ SMTP_HOST=
SMTP_PORT= SMTP_PORT=
SMTP_USER= SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_SENDER=
``` ```
Make sure you have them handy because you'd need them while we go through this selfhosting guide. Make sure you have them handy because you'd need them while we go through this selfhosting guide.
@@ -77,6 +78,7 @@ Go ahead and click this button to get started.
SMTP_HOST="" SMTP_HOST=""
SMTP_PORT="" SMTP_PORT=""
SMTP_USER="" SMTP_USER=""
SMTP_SENDER=""
SMTP_PASSWORD="" SMTP_PASSWORD=""
PUBLIC_SUPABASE_URL="" PUBLIC_SUPABASE_URL=""
PUBLIC_SUPABASE_ANON_KEY="" PUBLIC_SUPABASE_ANON_KEY=""
+1
View File
@@ -44,6 +44,7 @@ services:
- SMTP_USER=${SMTP_USER} - SMTP_USER=${SMTP_USER}
- SMTP_PASSWORD=${SMTP_PASSWORD} - SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_PORT=${SMTP_PORT} - SMTP_PORT=${SMTP_PORT}
- SMTP_SENDER=${SMTP_SENDER}
dashboard: dashboard:
build: build:
+3 -3
View File
@@ -9,9 +9,9 @@
"format": "prettier . --write", "format": "prettier . --write",
"release": "standard-version", "release": "standard-version",
"ci": "cypress run", "ci": "cypress run",
"supabase:start": "pnpx [email protected].1 start", "supabase:start": "pnpx [email protected].0 start",
"supabase:login": "pnpx [email protected].1 login", "supabase:login": "pnpx [email protected].0 login",
"supabase:status": "pnpx [email protected].1 status", "supabase:status": "pnpx [email protected].0 status",
"dashboard:build": "cd apps/dashboard && pnpm i && IS_SELFHOSTED=true NODE_OPTIONS='--max-old-space-size=20384' DEPLOYMENT_PROVIDER=docker pnpm build", "dashboard:build": "cd apps/dashboard && pnpm i && IS_SELFHOSTED=true NODE_OPTIONS='--max-old-space-size=20384' DEPLOYMENT_PROVIDER=docker pnpm build",
"dashboard:start": "cd apps/dashboard && node build" "dashboard:start": "cd apps/dashboard && node build"
}, },
+1788 -724
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -461,4 +461,12 @@ INSERT INTO "storage"."buckets" ("id", "name", "owner", "created_at", "updated_a
-- Get the sequence name from supabase database -- Get the sequence name from supabase database
SELECT PG_GET_SERIAL_SEQUENCE('organizationmember', 'id'); SELECT PG_GET_SERIAL_SEQUENCE('organizationmember', 'id');
-- In my case i got "public.organizationmember_id_seq" -- In my case i got "public.organizationmember_id_seq"
SELECT setval('public.organizationmember_id_seq', COALESCE((SELECT MAX(id)+1 FROM organizationmember), 1), false); SELECT setval('public.organizationmember_id_seq', COALESCE((SELECT MAX(id)+1 FROM organizationmember), 1), false);
--
-- Data for Name: organization_plan; Type: TABLE DATA; Schema: public; Owner: postgres
--
INSERT INTO "public"."organization_plan" ("id", "org_id", "plan_name", "is_active", "deactivated_at", "payload", "triggered_by", "activated_at", "updated_at", "provider", "subscription_id") VALUES
(1, '1a1dcddd-1abc-4f72-b644-0bd18191a289', 'ENTERPRISE', true, NULL, NULL, 12, '2023-11-22 09:29:04.533114+00', '2023-11-22 09:29:04.533114+00', 'lmz', NULL);