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 <iniubongobonguko2018@gmail.com>
This commit is contained in:
co-authored by
Iniubong Obonguko
parent
9eb7b01587
commit
82be557a42
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
.env
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
dist
|
||||
@@ -12,6 +12,7 @@ ARG SMTP_HOST
|
||||
ARG SMTP_USER
|
||||
ARG SMTP_PASSWORD
|
||||
ARG SMTP_PORT
|
||||
ARG SMTP_SENDER
|
||||
|
||||
RUN mkdir /app
|
||||
WORKDIR /app
|
||||
@@ -63,6 +64,8 @@ ENV NODE_ENV=production \
|
||||
SMTP_PORT=$SMTP_PORT \
|
||||
PORT=3002
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
EXPOSE 3002
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["pnpm", "start"]
|
||||
|
||||
+38
-18
@@ -1,44 +1,64 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "0.0.1",
|
||||
"description": "Express Server for long processing",
|
||||
"main": "server.js",
|
||||
"description": "ClassroomIO Backend for long processes",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
"build": "tsc && tsc-alias",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"format": "prettier --write \"src/**/*.ts\""
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/rotimi-best/classroomio.git"
|
||||
"url": "git+https://github.com/classroomio/classroomio.git"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/rotimi-best/classroomio/issues"
|
||||
"url": "https://github.com/classroomio/classroomio/issues"
|
||||
},
|
||||
"homepage": "https://github.com/rotimi-best/classroomio#readme",
|
||||
"homepage": "https://github.com/classroomio/classroomio#readme",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.388.0",
|
||||
"@aws-sdk/lib-storage": "^3.535.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.675.0",
|
||||
"@hono/node-server": "^1.8.2",
|
||||
"@sentry/node": "^7.110.1",
|
||||
"@sentry/profiling-node": "^7.110.1",
|
||||
"@supabase/supabase-js": "^2.32.0",
|
||||
"@types/nodemailer": "^6.4.11",
|
||||
"axios": "^1.4.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"bull": "^4.11.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.17.1",
|
||||
"express-rate-limit": "^6.9.0",
|
||||
"form-data": "^4.0.0",
|
||||
"hono": "^4.0.5",
|
||||
"hono-rate-limiter": "^0.4.2",
|
||||
"katex": "^0.16.10",
|
||||
"marked": "^7.0.3",
|
||||
"multer": "^1.4.3",
|
||||
"nanoid": "^5.1.5",
|
||||
"nodemailer": "^6.9.5",
|
||||
"nodemon": "^3.0.1",
|
||||
"puppeteer": "^24.10.0",
|
||||
"zeptomail": "^6.0.0",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/node": "^20.11.19",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/puppeteer": "^7.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.1",
|
||||
"@typescript-eslint/parser": "^7.0.1",
|
||||
"@vitest/coverage-v8": "^1.2.2",
|
||||
"eslint": "^8.56.0",
|
||||
"prettier": "^3.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.5.2",
|
||||
"vitest": "^1.2.2"
|
||||
},
|
||||
"_moduleAliases": {
|
||||
"$src": "src"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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();
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,31 +0,0 @@
|
||||
const express = require('express');
|
||||
// https://ben.page/node-latex-to-html
|
||||
const katex = require('katex');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// localhost:3002/katex
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const original = req._parsedUrl.search;
|
||||
|
||||
const latexString = original
|
||||
.replace('?', '')
|
||||
.replaceAll('+', '+')
|
||||
.replaceAll('&space;', '');
|
||||
console.log({ latexString });
|
||||
|
||||
const html = katex.renderToString(latexString, {
|
||||
throwOnError: false,
|
||||
output: 'mathml'
|
||||
});
|
||||
|
||||
console.log({ html });
|
||||
|
||||
res.send(html);
|
||||
} catch (error) {
|
||||
res.send('failed');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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" <notify@classroomio.com>',
|
||||
to,
|
||||
subject,
|
||||
replyTo,
|
||||
html: withEmailTemplate(content)
|
||||
});
|
||||
}
|
||||
|
||||
// format: "ClassroomIO Developers (via ClassroomIO.com)" <notify@mail.classroomio.com>
|
||||
function extractNameAndEmail(str) {
|
||||
// Use regular expressions to match the name and email
|
||||
const regex = /"(.*?)"\s+<\s*(.*?)@(.*?)\s*>/;
|
||||
const match = str.match(regex);
|
||||
|
||||
if (match) {
|
||||
// Extract the name and email from the match groups
|
||||
const name = match[1];
|
||||
const email = match[2] + '@' + match[3];
|
||||
return { name, email };
|
||||
} else {
|
||||
// Return undefined if the format doesn't match
|
||||
return { name: from, email: from };
|
||||
}
|
||||
}
|
||||
|
||||
async function sendWithZoho(emailData) {
|
||||
const { from, to, subject, content } = emailData;
|
||||
|
||||
const fromData = extractNameAndEmail(from);
|
||||
|
||||
return zohoClient.sendMail({
|
||||
from: {
|
||||
address: fromData.email,
|
||||
name: fromData.name
|
||||
},
|
||||
to: [
|
||||
{
|
||||
email_address: {
|
||||
address: to
|
||||
}
|
||||
}
|
||||
],
|
||||
subject,
|
||||
htmlbody: content
|
||||
});
|
||||
}
|
||||
|
||||
emailRouter.post('/', async (req, res) => {
|
||||
try {
|
||||
const mySchema = zod.array(
|
||||
zod.object({
|
||||
from: zod.string().optional(),
|
||||
to: zod.string(),
|
||||
subject: zod.string(),
|
||||
content: zod.string(),
|
||||
replyTo: zod.string().optional()
|
||||
})
|
||||
);
|
||||
|
||||
mySchema.parse(req.body);
|
||||
|
||||
const emailDataArray = req.body;
|
||||
|
||||
Promise.all(
|
||||
emailDataArray.map(async (emailData) => {
|
||||
let res;
|
||||
|
||||
try {
|
||||
if (!ZOHO_TOKEN) {
|
||||
res = await sendWithNodemailer(emailData);
|
||||
} else {
|
||||
res = await sendWithZoho(emailData);
|
||||
}
|
||||
|
||||
console.log('Email status', res);
|
||||
} catch (error) {
|
||||
console.error('Error sending email', error, error?.error?.details?.[0]);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
console.log('Email sent');
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error sending emails:', error);
|
||||
return res.status(400).json({ error: `Bad request - ${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = emailRouter;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { TCertificateDownload } from '$src/types/course/lesson';
|
||||
import { getPdfBuffer } from '$src/utils/puppeteer';
|
||||
|
||||
export interface CertificateData {
|
||||
theme: string;
|
||||
studentName: string;
|
||||
courseName: string;
|
||||
courseDescription: string;
|
||||
orgLogoUrl: string;
|
||||
orgName: string;
|
||||
facilitator?: string;
|
||||
}
|
||||
|
||||
export interface CertificateGenerationResult {
|
||||
success: boolean;
|
||||
buffer?: Buffer;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const generateCertificate = async (data: TCertificateDownload): Promise<Buffer> => {
|
||||
const html = generateCertificateHtml(data);
|
||||
return getPdfBuffer(html);
|
||||
};
|
||||
|
||||
function generateCertificateHtml(data: TCertificateDownload): string {
|
||||
const { studentName, courseName, courseDescription, orgLogoUrl, orgName, facilitator } = data;
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Certificate of Completion</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.certificate {
|
||||
width: 1123px;
|
||||
height: 794px;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
position: relative;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.logo {
|
||||
max-width: 200px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.title {
|
||||
font-size: 36px;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.content {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
.student-name {
|
||||
font-size: 48px;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.course-name {
|
||||
font-size: 24px;
|
||||
color: #34495e;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.description {
|
||||
font-size: 18px;
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.signature {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.date {
|
||||
margin-top: 10px;
|
||||
color: #95a5a6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="certificate">
|
||||
<div class="header">
|
||||
<img src="${orgLogoUrl}" alt="${orgName} Logo" class="logo">
|
||||
<h1 class="title">Certificate of Completion</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2 class="student-name">${studentName}</h2>
|
||||
<p class="course-name">${courseName}</p>
|
||||
<p class="description">${courseDescription}</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="signature">
|
||||
<p>${facilitator || 'Course Facilitator'}</p>
|
||||
<p>${orgName}</p>
|
||||
</div>
|
||||
<p class="date">${new Date().toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
// const { getPdfBuffer } = require('./puppeteer');
|
||||
const { marked } = require('marked');
|
||||
// const puppeteer = require('puppeteer');
|
||||
import type { TCourseDownloadContent } from '$src/types/course';
|
||||
import { marked } from 'marked';
|
||||
|
||||
function getHtmlTemplate(body) {
|
||||
function getHtmlTemplate(body: string): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -71,22 +70,20 @@ function getHtmlTemplate(body) {
|
||||
`;
|
||||
}
|
||||
|
||||
type TLessonBody = TCourseDownloadContent['lessons'][0];
|
||||
|
||||
function getLessonBody(
|
||||
{ lessonTitle, lessonNumber, lessonNote, slideUrl, video },
|
||||
orgName,
|
||||
orgTheme
|
||||
) {
|
||||
{ lessonTitle, lessonNumber, lessonNote, slideUrl, video }: TLessonBody,
|
||||
orgName: string,
|
||||
orgTheme: string
|
||||
): string {
|
||||
const noteHtml = marked.parse(lessonNote);
|
||||
const showExtraResources = slideUrl || (video && video.length > 0);
|
||||
return `
|
||||
<div
|
||||
class="bg-[${
|
||||
orgTheme ? orgTheme : '#0030FF'
|
||||
}] w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
|
||||
class="bg-[${orgTheme ? orgTheme : '#0030FF'}] w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
|
||||
<h3
|
||||
class="my-0 rounded-full w-10 text-center mx-auto py-1 text-[${
|
||||
orgTheme ? orgTheme : '#0030FF'
|
||||
}] bg-white text-md font-bold tracking-tighter">
|
||||
class="my-0 rounded-full w-10 text-center mx-auto py-1 text-[${orgTheme ? orgTheme : '#0030FF'}] bg-white text-md font-bold tracking-tighter">
|
||||
${lessonNumber}
|
||||
</h3>
|
||||
<h1 class="text-white m-0">${lessonTitle}</h1>
|
||||
@@ -140,83 +137,34 @@ function getLessonBody(
|
||||
`;
|
||||
}
|
||||
|
||||
function getCourseBody({ courseTitle, orgName, orgTheme, lessons }) {
|
||||
function getCourseBody({
|
||||
courseTitle,
|
||||
orgName,
|
||||
orgTheme,
|
||||
lessons
|
||||
}: TCourseDownloadContent): string {
|
||||
return `
|
||||
<header class="flex justify-center items-center flex-col border-[${
|
||||
orgTheme ? orgTheme : '#0030FF'
|
||||
}] border-[40px] mx-3 h-[100vh] m-0">
|
||||
<header class="flex justify-center items-center flex-col border-[${orgTheme ? orgTheme : '#0030FF'}] border-[40px] mx-3 h-[100vh] m-0">
|
||||
<h1 class="font-bold text-8xl text-black m-0 mx-96">${courseTitle}</h1>
|
||||
<h4 class="text-md text-2xl font-light text-black mt-5 ">${orgName}</h4>
|
||||
</header>
|
||||
<div class="root mx-auto mt-5 prose break-after-page">
|
||||
${lessons.map((lesson) => getLessonBody(lesson, orgName, orgTheme))}
|
||||
${lessons.map((lesson) => getLessonBody(lesson, orgName, orgTheme)).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function generateSinglePdfFromHtml(htmlContent, courseTitle, orgTheme) {
|
||||
async function generateSinglePdfFromHtml(
|
||||
htmlContent: string,
|
||||
courseTitle: string,
|
||||
orgTheme: string
|
||||
): Promise<Buffer> {
|
||||
throw 'Download disabled';
|
||||
// const args = [
|
||||
// '--disable-web-security',
|
||||
// '--no-sandbox',
|
||||
// '--disable-setuid-sandbox',
|
||||
// ];
|
||||
// const browser = await puppeteer.launch({
|
||||
// headless: 'new',
|
||||
// args,
|
||||
// });
|
||||
// const page = await browser.newPage();
|
||||
|
||||
// await page.setContent(htmlContent);
|
||||
|
||||
// const pdfOptions = {
|
||||
// format: 'A4',
|
||||
// printBackground: true,
|
||||
// displayHeaderFooter: true,
|
||||
// headerTemplate:
|
||||
// "<div><div class='pageNumber'></div> <div>/</div><div class='totalPages'></div></div>",
|
||||
// footerTemplate: `
|
||||
// <div class="footer" style="position: relative; display: flex; text-align: right; margin-right: 5%; font-size: 8px; width: 297mm; padding-top: 30px;">
|
||||
// <div style="position: absolute; right: 50%; bottom: 3%;"><span class="pageNumber"></span>/<span class="totalPages"></span></div>
|
||||
// <div style="position: absolute; right: 0; bottom: 3%;">${courseTitle} | Powered by <a href="https://app.classroomio.com" style="text-decoration: underline;">ClassroomIO</a></div>
|
||||
// </div>
|
||||
// `,
|
||||
// margin: {
|
||||
// top: '0.3cm',
|
||||
// bottom: '0.5cm',
|
||||
// },
|
||||
// };
|
||||
|
||||
// const numPages = await page.evaluate(() => {
|
||||
// const content = document.body; // Change this to the specific element containing content
|
||||
// const style = window.getComputedStyle(content);
|
||||
// const contentHeight = parseInt(style.height, 10);
|
||||
// const pageHeight = 900; // Set this to an appropriate value based on your PDF format
|
||||
|
||||
// return Math.ceil(contentHeight / pageHeight);
|
||||
// });
|
||||
|
||||
// const pagesPdfBuffer = [];
|
||||
|
||||
// for (let currentPage = 1; currentPage <= numPages; currentPage++) {
|
||||
// await page.evaluate(() => {});
|
||||
|
||||
// const pdfBuffer = await page.pdf(pdfOptions);
|
||||
// pagesPdfBuffer.push(pdfBuffer);
|
||||
// }
|
||||
|
||||
// await browser.close();
|
||||
// return Buffer.concat(pagesPdfBuffer);
|
||||
}
|
||||
|
||||
async function generateCoursePdf(params) {
|
||||
// Generate the course boddy
|
||||
export async function generateCoursePdf(params: TCourseDownloadContent): Promise<Buffer> {
|
||||
const body = getCourseBody(params);
|
||||
const html = getHtmlTemplate(body);
|
||||
console.log(html);
|
||||
return await generateSinglePdfFromHtml(html, params.courseTitle);
|
||||
return await generateSinglePdfFromHtml(html, params.courseTitle, params.orgTheme);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateCoursePdf
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,35 @@
|
||||
import { SendMailClient } from 'zeptomail';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
import { env } from '$src/config/env';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
export const zohoClient = new SendMailClient({
|
||||
url: 'api.zeptomail.eu/',
|
||||
token: env.ZOHO_TOKEN!
|
||||
});
|
||||
|
||||
export const nodemailerTransporter = async (): Promise<Transporter | null> => {
|
||||
if (!env.SMTP_HOST || !env.SMTP_PORT || !env.SMTP_USER || !env.SMTP_PASSWORD) {
|
||||
console.error('SMTP configuration missing');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: parseInt(env.SMTP_PORT || '465', 10),
|
||||
secure: true,
|
||||
auth: {
|
||||
user: env.SMTP_USER,
|
||||
pass: env.SMTP_PASSWORD
|
||||
}
|
||||
});
|
||||
|
||||
await transporter.verify();
|
||||
|
||||
return transporter;
|
||||
} catch (error) {
|
||||
console.error('Transporter error:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,3 @@
|
||||
module.exports = () => {
|
||||
export default function genUniqueId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
// const { getPdfBuffer } = require('./puppeteer');
|
||||
const { marked } = require('marked');
|
||||
// const puppeteer = require('puppeteer');
|
||||
import type { TLessonDownloadContent } from '$src/types/course/lesson';
|
||||
import { marked } from 'marked';
|
||||
|
||||
function getHtmlTemplate(body) {
|
||||
function getHtmlTemplate(body: string): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -75,7 +74,15 @@ function getHtmlTemplate(body) {
|
||||
`;
|
||||
}
|
||||
|
||||
function getLessonBody({ title, number, orgName, note, slideUrl, video, courseTitle }) {
|
||||
function getLessonBody({
|
||||
title,
|
||||
number,
|
||||
orgName,
|
||||
note,
|
||||
slideUrl,
|
||||
video,
|
||||
courseTitle
|
||||
}: TLessonDownloadContent): string {
|
||||
const noteHtml = marked.parse(note);
|
||||
const showExtraResources = slideUrl || (video && video.length > 0);
|
||||
return `
|
||||
@@ -139,83 +146,16 @@ function getLessonBody({ title, number, orgName, note, slideUrl, video, courseTi
|
||||
`;
|
||||
}
|
||||
|
||||
async function generateSinglePdfFromHtml(htmlContent, courseTitle) {
|
||||
// const args = [
|
||||
// '--disable-web-security',
|
||||
// '--no-sandbox',
|
||||
// '--disable-setuid-sandbox',
|
||||
// ];
|
||||
// const browser = await puppeteer.launch({
|
||||
// headless: 'new',
|
||||
// args,
|
||||
// });
|
||||
// const page = await browser.newPage();
|
||||
|
||||
// await page.setContent(htmlContent);
|
||||
|
||||
// const pdfOptions = {
|
||||
// format: 'A4',
|
||||
// printBackground: true,
|
||||
// displayHeaderFooter: true,
|
||||
// headerTemplate:
|
||||
// "<div><div class='pageNumber'></div> <div>/</div><div class='totalPages'></div></div>",
|
||||
// footerTemplate: `<div class="footer" style="text-align: right; margin-right: 5%; font-size: 8px; width: 297mm; padding-top: 30px;">
|
||||
// ${courseTitle} | Powered by <a href="https://app.classroomio.com" style="color: blue; text-decoration: underline;">ClassroomIO</a>
|
||||
// </div>`,
|
||||
// margin: {
|
||||
// top: '0.4cm',
|
||||
// bottom: '1cm',
|
||||
// },
|
||||
// };
|
||||
|
||||
// const numPages = await page.evaluate(() => {
|
||||
// const content = document.body; // Change this to the specific element containing content
|
||||
// const style = window.getComputedStyle(content);
|
||||
// const contentHeight = parseInt(style.height, 10);
|
||||
// const pageHeight = 900; // Set this to an appropriate value based on your PDF format
|
||||
|
||||
// return Math.ceil(contentHeight / pageHeight);
|
||||
// });
|
||||
|
||||
// const pagesPdfBuffer = [];
|
||||
|
||||
// for (let currentPage = 1; currentPage <= numPages; currentPage++) {
|
||||
// await page.evaluate(() => {});
|
||||
|
||||
// const pdfBuffer = await page.pdf(pdfOptions);
|
||||
// pagesPdfBuffer.push(pdfBuffer);
|
||||
// }
|
||||
|
||||
// await browser.close();
|
||||
// return Buffer.concat(pagesPdfBuffer);
|
||||
|
||||
async function generateSinglePdfFromHtml(
|
||||
htmlContent: string,
|
||||
courseTitle: string
|
||||
): Promise<Buffer> {
|
||||
throw 'Download disabled';
|
||||
}
|
||||
|
||||
async function generateLessonPdf(params) {
|
||||
// Generate the lesson boddy
|
||||
export async function generateLessonPdf(params: TLessonDownloadContent): Promise<Buffer> {
|
||||
const body = getLessonBody(params);
|
||||
const html = getHtmlTemplate(body);
|
||||
console.log(html);
|
||||
return await generateSinglePdfFromHtml(html, params.courseTitle);
|
||||
// return await getPdfBuffer(html, {
|
||||
// path: 'test.pdf',
|
||||
// width: '1000px',
|
||||
// height: undefined,
|
||||
// printBackground: true,
|
||||
// format: 'A4',
|
||||
// displayHeaderFooter: true,
|
||||
// headerTemplate:
|
||||
// "<div><div class='pageNumber'></div> <div>/</div><div class='totalPages'></div></div>",
|
||||
// footerTemplate:
|
||||
// '<div style="text-align: right;width: 297mm;font-size: 8px;"><span style="margin-right: 1cm"><span class="pageNumber"></span> of <span class="totalPages"></span></span></div>',
|
||||
// margin: {
|
||||
// top: '1cm',
|
||||
// bottom: '1cm',
|
||||
// },
|
||||
// });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateLessonPdf
|
||||
};
|
||||
@@ -1,4 +1,26 @@
|
||||
const withEmailTemplate = (content) =>
|
||||
interface FromData {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// format: "ClassroomIO Developers (via ClassroomIO.com)" <notify@mail.classroomio.com>
|
||||
export function extractNameAndEmail(str: string): FromData | undefined {
|
||||
// Use regular expressions to match the name and email
|
||||
const regex = /"(.*?)"\s+<\s*(.*?)@(.*?)\s*>/;
|
||||
const match = str.match(regex);
|
||||
|
||||
if (match) {
|
||||
// Extract the name and email from the match groups
|
||||
const name = match[1];
|
||||
const email = match[2] + '@' + match[3];
|
||||
return { name, email };
|
||||
} else {
|
||||
// Return undefined if the format doesn't match
|
||||
return { name: str, email: str };
|
||||
}
|
||||
}
|
||||
|
||||
export const withEmailTemplate = (content: string): string =>
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
@@ -189,4 +211,3 @@ const withEmailTemplate = (content) =>
|
||||
|
||||
</html>
|
||||
`;
|
||||
module.exports = { withEmailTemplate };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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/']
|
||||
}
|
||||
}
|
||||
});
|
||||
+14
-15
@@ -13,6 +13,7 @@
|
||||
import { fetchProfileCourseProgress } from '$lib/utils/services/courses';
|
||||
import type { ProfileCourseProgress } from '$lib/utils/types';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { apiClient } from '$lib/utils/services/api';
|
||||
|
||||
let isLoading = false;
|
||||
let isCourseComplete = false;
|
||||
@@ -23,24 +24,22 @@
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const response = await fetch(env.PUBLIC_SERVER_URL + '/downloadCertificate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
const response = await apiClient.post(
|
||||
'/course/download/certificate',
|
||||
{
|
||||
theme: `${$course.certificate_theme}`,
|
||||
studentName: `${$profile.fullname}`,
|
||||
courseName: `${$course.title}`,
|
||||
courseDescription: `${$course.description}`,
|
||||
orgLogoUrl: `${$currentOrg.avatar_url}`,
|
||||
orgName: `${$currentOrg.name}`
|
||||
})
|
||||
});
|
||||
const data = await response.blob();
|
||||
console.log(data);
|
||||
const file = new Blob([data], { type: 'application/pdf' });
|
||||
},
|
||||
{
|
||||
responseType: 'blob'
|
||||
}
|
||||
);
|
||||
|
||||
const file = new Blob([response.data], { type: 'application/pdf' });
|
||||
const fileURL = URL.createObjectURL(file);
|
||||
|
||||
let a = document.createElement('a');
|
||||
@@ -85,12 +84,12 @@
|
||||
</script>
|
||||
|
||||
<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]" />
|
||||
<p class="text-xl font-normal text-center">
|
||||
<p class="text-center text-xl font-normal">
|
||||
{$t(title)}
|
||||
</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)}
|
||||
</p>
|
||||
<PrimaryButton
|
||||
|
||||
+3
-2
@@ -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 TextField from '$lib/components/Form/TextField.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
@@ -26,7 +27,7 @@
|
||||
$lesson.materials.videos = [
|
||||
...existingLinks,
|
||||
...validLinks.map((link = '') => ({
|
||||
type: 'generic',
|
||||
type: 'generic' as LessonVideoType,
|
||||
link,
|
||||
metadata: {}
|
||||
}))
|
||||
|
||||
+9
-9
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import YoutubeVideo from './YoutubeVideo.svelte';
|
||||
import LocalVideo from './LocalVideo.svelte';
|
||||
import UploadVideo from './UploadVideo.svelte';
|
||||
import EmbedLink from './EmbedLink.svelte';
|
||||
import * as CONSTANTS from './constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -9,25 +9,25 @@
|
||||
|
||||
export let lessonId = '';
|
||||
|
||||
let tabs = CONSTANTS.videoTabs;
|
||||
const tabs = CONSTANTS.videoTabs;
|
||||
let currentTab = tabs[0].value;
|
||||
|
||||
const onChange = (tab) => () => (currentTab = tab);
|
||||
</script>
|
||||
|
||||
<section class="w-full h-full flex flex-col md:flex-row items-start gap-3">
|
||||
<div class="flex flex-row md:flex-col gap-2 items-center">
|
||||
<p class="text-sm font-normal text-start text-[#4F4B4B] dark:text-[#b0a9a9] mb-3 w-full">
|
||||
<section class="flex h-full w-full flex-col items-start gap-3 md:flex-row">
|
||||
<div class="flex flex-row items-center gap-2 md:flex-col">
|
||||
<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')}
|
||||
</p>
|
||||
{#each tabs as item (item.value)}
|
||||
<button
|
||||
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
|
||||
? 'border border-[#0233BD] bg-[#F5F8FE] dark:text-black'
|
||||
: '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}
|
||||
<FlashFilled size={20} class="text-blue-700" />
|
||||
@@ -42,13 +42,13 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<main class="w-full h-full">
|
||||
<main class="h-full w-full">
|
||||
{#if currentTab === 1}
|
||||
<YoutubeVideo />
|
||||
{:else if currentTab === 2}
|
||||
<EmbedLink />
|
||||
{:else}
|
||||
<LocalVideo {lessonId} />
|
||||
<UploadVideo {lessonId} />
|
||||
{/if}
|
||||
</main>
|
||||
</section>
|
||||
|
||||
+72
-77
@@ -1,69 +1,96 @@
|
||||
<script lang="ts">
|
||||
import axios from 'axios';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
lesson,
|
||||
deleteLessonVideo,
|
||||
uploadCourseVideoStore
|
||||
} 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 { supabase } from '$lib/utils/functions/supabase';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
|
||||
|
||||
let value = 0;
|
||||
let max = 100;
|
||||
let status = 'active';
|
||||
let fileSize;
|
||||
let isDisabled = false;
|
||||
import { apiClient } from '$lib/utils/services/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
export let lessonId = '';
|
||||
|
||||
let value = 0;
|
||||
let max = 100;
|
||||
let status: ComponentProps<ProgressBar>['status'] = 'active';
|
||||
let fileSize;
|
||||
let isDisabled = false;
|
||||
|
||||
let formRes;
|
||||
let isLoaded = false;
|
||||
let fileInput;
|
||||
let submit;
|
||||
let uploadedFileUrl = '';
|
||||
let isLoading = false;
|
||||
let prevProgress = 0;
|
||||
let uploadChannel;
|
||||
let fileName = '';
|
||||
|
||||
function isVideoAdded(link) {
|
||||
return $lesson.materials?.videos?.find((v) => v.link === link);
|
||||
}
|
||||
|
||||
async function onUpload(e) {
|
||||
isLoading = true;
|
||||
async function onUpload() {
|
||||
if (!fileInput) return;
|
||||
|
||||
const formData = new FormData();
|
||||
$uploadCourseVideoStore.isUploading = true;
|
||||
|
||||
const videoFile = fileInput.files[0];
|
||||
formData.append('videoFile', videoFile);
|
||||
|
||||
console.log({ size: videoFile?.size });
|
||||
|
||||
fileSize = videoFile?.size / (1024 * 1024);
|
||||
|
||||
try {
|
||||
const response = await axios({
|
||||
method: 'POST',
|
||||
url: env.PUBLIC_SERVER_URL + '/uploadVideo?lessonId=' + lessonId,
|
||||
data: formData,
|
||||
const uploadPresignResponse = await apiClient.post('/course/presign/upload', {
|
||||
fileName: videoFile.name,
|
||||
fileType: videoFile.type
|
||||
});
|
||||
|
||||
const presignedUrl = uploadPresignResponse.data.url;
|
||||
|
||||
const uploadResponse = await axios.put(presignedUrl, videoFile, {
|
||||
headers: {
|
||||
'Content-Type': videoFile.type
|
||||
},
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data; boundary=MyBoundary'
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
value = Math.round((progressEvent.loaded * 100) / progressEvent?.total / 2);
|
||||
value = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 1) / 2);
|
||||
}
|
||||
});
|
||||
|
||||
formRes = response.data;
|
||||
console.log('Upload res', formRes);
|
||||
isLoading = false;
|
||||
const downloadPresignedResponse = await apiClient.post('/course/presign/download', {
|
||||
keys: [uploadPresignResponse.data.fileKey]
|
||||
});
|
||||
|
||||
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;
|
||||
} catch (err: any) {
|
||||
console.error('Error uploading video', err, '\n\n', err.response);
|
||||
@@ -76,50 +103,20 @@
|
||||
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() {
|
||||
formRes = null;
|
||||
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';
|
||||
$: if (value === max) {
|
||||
helperText = 'Done';
|
||||
status = 'finished';
|
||||
}
|
||||
|
||||
$: isDoneUploading(formRes);
|
||||
$: isDisabled = isLoading || !env.PUBLIC_SERVER_URL || $isFreePlan;
|
||||
$: isDisabled = $uploadCourseVideoStore.isUploading || !env.PUBLIC_SERVER_URL || $isFreePlan;
|
||||
</script>
|
||||
|
||||
<UpgradeBanner className="mb-3" onClick={() => ($uploadCourseVideoStore.isModalOpen = false)}>
|
||||
@@ -129,16 +126,16 @@
|
||||
{#if !isLoaded}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (fileInput && !isLoading ? fileInput.click() : null)}
|
||||
class="h-full w-full {isDisabled && 'hover:cursor-not-allowed opacity-50'}"
|
||||
on:click={() => (fileInput && !$uploadCourseVideoStore.isUploading ? fileInput.click() : null)}
|
||||
class="h-full w-full {isDisabled && 'opacity-50 hover:cursor-not-allowed'}"
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<form
|
||||
class="border-primary-300 flex h-full w-full flex-col items-center justify-center rounded-xl border border-dashed"
|
||||
on:submit|preventDefault={onUpload}
|
||||
>
|
||||
{#if isLoading}
|
||||
<div class="flex flex-col gap-5 max-w-[500px] w-[60%] justify-center">
|
||||
{#if $uploadCourseVideoStore.isUploading}
|
||||
<div class="flex w-[60%] max-w-[500px] flex-col justify-center gap-5">
|
||||
<p class="mt-5 text-center">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.add_video.uploading')}
|
||||
</p>
|
||||
@@ -186,7 +183,7 @@
|
||||
onClick={tryAgain}
|
||||
/>
|
||||
</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">
|
||||
<img src="/video-upload-error.svg" alt="upload error" />
|
||||
<span class="pb-2 pt-3">
|
||||
@@ -210,13 +207,11 @@
|
||||
<span class="flex items-center gap-2">
|
||||
<div class="overflow-hidden rounded-sm">
|
||||
<video class="w-[200px]">
|
||||
<source src={uploadedFileUrl} type="video/mp4" />
|
||||
<!-- <source src="/path/to/video.webm" type="video/webm" /> -->
|
||||
<!-- Captions are optional -->
|
||||
<source src={formRes.url} type="video/mp4" />
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
</div>
|
||||
<p>{fileInput?.files?.[0]?.name || uploadedFileUrl}</p>
|
||||
<p>{fileInput?.files?.[0]?.name || fileName}</p>
|
||||
</span>
|
||||
<button on:click={deleteLessonVideo}>
|
||||
<img src="/delete-video.svg" alt="deletevideo" class="dark:invert" />
|
||||
+4
-3
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { CopyButton, Tag } from 'carbon-components-svelte';
|
||||
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.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 PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { LessonVideoType } from '$lib/utils/types';
|
||||
|
||||
let youtubeLinks = '';
|
||||
let error = '';
|
||||
@@ -26,7 +27,7 @@
|
||||
$lesson.materials.videos = [
|
||||
...existingLinks,
|
||||
...validLinks.map((link = '') => ({
|
||||
type: 'youtube',
|
||||
type: 'youtube' as LessonVideoType,
|
||||
link,
|
||||
metadata: {}
|
||||
}))
|
||||
@@ -48,7 +49,7 @@
|
||||
}
|
||||
</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
|
||||
label={$t('course.navItem.lessons.materials.tabs.video.add_video.youtube_link')}
|
||||
bind:value={youtubeLinks}
|
||||
|
||||
+33
-16
@@ -1,29 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { formatYoutubeVideo } from '$lib/utils/functions/formatYoutubeVideo';
|
||||
import { lesson } from '../../store/lessons';
|
||||
|
||||
let errors = {};
|
||||
let player = null;
|
||||
let errors: Record<string, string> = {};
|
||||
let videoElements: (HTMLVideoElement | null)[] = [];
|
||||
|
||||
function initPlyr(_player: any, _video: string | undefined) {
|
||||
if (!_player) return;
|
||||
|
||||
const players = Array.from(document.querySelectorAll('.plyr-video-trigger')).map((p) => {
|
||||
// @ts-ignore
|
||||
return new Plyr(p);
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
window.players = players;
|
||||
function initPlyr() {
|
||||
if (typeof (window as any).Plyr === 'undefined') {
|
||||
console.warn('Plyr is not defined. Make sure it is properly imported.');
|
||||
return;
|
||||
}
|
||||
const players = Array.from(document.querySelectorAll('.plyr-video-trigger')).map(
|
||||
(p) => new (window as any).Plyr(p as HTMLElement)
|
||||
);
|
||||
if (typeof window !== 'undefined') {
|
||||
(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>
|
||||
|
||||
{#if $lesson.materials.videos.length}
|
||||
<div class="w-full">
|
||||
{#each $lesson.materials.videos as video}
|
||||
<div class="w-full overflow-hidden mb-5">
|
||||
{#each $lesson.materials.videos as video, index}
|
||||
<div class="mb-5 w-full overflow-hidden">
|
||||
{#key video.link}
|
||||
<div class="mb-5">
|
||||
{#if video.type === 'youtube'}
|
||||
@@ -58,7 +70,12 @@
|
||||
/>
|
||||
</div>
|
||||
{: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" />
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
|
||||
@@ -59,9 +59,7 @@
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
let tabs = CONSTANTS.tabs;
|
||||
let currentTab = tabs[0].value;
|
||||
let errors: {
|
||||
video: string;
|
||||
};
|
||||
let errors: Record<string, string> = {};
|
||||
let editorWindowRef: Window;
|
||||
let aiButtonRef: HTMLDivElement;
|
||||
let openPopover = false;
|
||||
@@ -268,7 +266,10 @@
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
if ($uploadCourseVideoStore.isUploading) return;
|
||||
|
||||
$uploadCourseVideoStore.isModalOpen = false;
|
||||
autoSave($lesson.materials, $lessonByTranslation[lessonId], $isLoading, lessonId);
|
||||
};
|
||||
|
||||
function getComponentOrder(tabs = CONSTANTS.tabs) {
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Writable, Updater } from 'svelte/store';
|
||||
import type { Course, Lesson, LessonComment, LessonPage, LessonSection } from '$lib/utils/types';
|
||||
import type { Updater, Writable } from 'svelte/store';
|
||||
import {
|
||||
createLesson,
|
||||
updateLesson,
|
||||
deleteLesson,
|
||||
createLessonSection,
|
||||
updateLessonSection,
|
||||
deleteLessonSection
|
||||
deleteLesson,
|
||||
deleteLessonSection,
|
||||
updateLesson,
|
||||
updateLessonSection
|
||||
} from '$lib/utils/services/courses';
|
||||
import type { Lesson, Course, LessonPage, LessonComment, LessonSection } 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 { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const uploadCourseVideoStore = writable({
|
||||
isUploading: false,
|
||||
isModalOpen: false
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="overflow-y-auto {isPageNavHidden
|
||||
? '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);
|
||||
const prefix = 'https://www.youtube.com/embed';
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ export const addMathPlugin = (tinymce: any) => {
|
||||
|
||||
if (srcMatch) {
|
||||
// 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);
|
||||
|
||||
// 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 {
|
||||
Course,
|
||||
Exercise,
|
||||
@@ -15,12 +10,19 @@ import type {
|
||||
ProfileCourseProgress
|
||||
} from '$lib/utils/types';
|
||||
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 { 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) {
|
||||
if (!orgId || !profileId) return;
|
||||
|
||||
const match = {};
|
||||
const match: { member_profile_id?: string } = {};
|
||||
// Filter by profile_id if role isn't admin within organization
|
||||
if (!get(isOrgAdmin)) {
|
||||
match.member_profile_id = profileId;
|
||||
@@ -257,15 +259,13 @@ export async function getMarks(courseId) {
|
||||
if (!courseId) return;
|
||||
|
||||
// Gets courses for a particular organisation where the current logged in user is a groupmember
|
||||
const { data: marks } = await supabase
|
||||
.rpc('get_marks')
|
||||
.eq('course_id', courseId);
|
||||
const { data: marks } = await supabase.rpc('get_marks').eq('course_id', courseId);
|
||||
|
||||
return { marks };
|
||||
}
|
||||
|
||||
export function fetchLesson(lessonId: Lesson['id']) {
|
||||
return supabase
|
||||
export async function fetchLesson(lessonId: Lesson['id']) {
|
||||
const { data, error } = await supabase
|
||||
.from('lesson')
|
||||
.select(
|
||||
`id,
|
||||
@@ -282,6 +282,29 @@ export function fetchLesson(lessonId: Lesson['id']) {
|
||||
)
|
||||
.eq('id', lessonId)
|
||||
.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) {
|
||||
|
||||
@@ -279,10 +279,7 @@ export interface Group_attendance {
|
||||
groupmember?: Groupmember;
|
||||
}
|
||||
|
||||
export enum VideoType {
|
||||
youtube,
|
||||
muse
|
||||
}
|
||||
export type LessonVideoType = 'youtube' | 'generic' | 'upload';
|
||||
|
||||
export interface LessonPage {
|
||||
id?: string | null;
|
||||
@@ -296,8 +293,9 @@ export interface LessonPage {
|
||||
note: string;
|
||||
slide_url: string;
|
||||
videos: Array<{
|
||||
type: string;
|
||||
type: LessonVideoType;
|
||||
link: string;
|
||||
key?: string;
|
||||
metadata?: {
|
||||
svid?: string;
|
||||
};
|
||||
|
||||
@@ -1,35 +1,36 @@
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
const sendEmail = async (
|
||||
emailDataArray: {
|
||||
from?: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
content: string;
|
||||
replyTo?: string;
|
||||
isPersonalEmail?: boolean;
|
||||
}[]
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(`${env.PUBLIC_SERVER_URL}/sendEmail`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(emailDataArray)
|
||||
});
|
||||
const sendEmail = (sFetch: typeof fetch) => {
|
||||
return async (
|
||||
emailDataArray: {
|
||||
from?: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
content: string;
|
||||
replyTo?: string;
|
||||
isPersonalEmail?: boolean;
|
||||
}[]
|
||||
) => {
|
||||
try {
|
||||
const response = await sFetch(`${env.PUBLIC_SERVER_URL}/mail/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(emailDataArray)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.log('Failed to send emails');
|
||||
if (!response.ok) {
|
||||
console.log('Failed to send emails');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Emails sent successfully:', response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error sending emails:', error);
|
||||
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;
|
||||
|
||||
+1
@@ -1,5 +1,6 @@
|
||||
import { Configuration, OpenAIApi } from 'openai-edge';
|
||||
import { OpenAIStream, StreamingTextResponse } from 'ai';
|
||||
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
// Create an OpenAI API client (that's edge friendly!)
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import sendEmail from '$mail/sendEmail';
|
||||
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();
|
||||
|
||||
if (!to || !content || !orgName) {
|
||||
@@ -17,7 +17,7 @@ export async function POST({ request }) {
|
||||
}
|
||||
];
|
||||
|
||||
await sendEmail(emailData);
|
||||
await sendEmail(fetch)(emailData);
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
@@ -1,11 +1,16 @@
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
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 sendEmail from '$mail/sendEmail';
|
||||
|
||||
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({
|
||||
supabase,
|
||||
feedId,
|
||||
@@ -34,7 +39,7 @@ const sendEmailNotification = async (feedId: string, authorId: string, comment?:
|
||||
}
|
||||
];
|
||||
|
||||
await sendEmail(emailData);
|
||||
await sendEmail(sFetch)(emailData);
|
||||
|
||||
// dont continue
|
||||
return;
|
||||
@@ -62,10 +67,10 @@ const sendEmailNotification = async (feedId: string, authorId: string, comment?:
|
||||
console.log('Sending emails to all students', feed.courseMembers.length);
|
||||
|
||||
// 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();
|
||||
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 });
|
||||
}
|
||||
|
||||
await sendEmailNotification(feedId, authorId, comment);
|
||||
await sendEmailNotification(fetch, feedId, authorId, comment);
|
||||
// TODO: Support sending to other platforms like telegram and discord
|
||||
// sendDiscordNotification(); sendTelegramNotification();
|
||||
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import sendEmail from '$mail/sendEmail';
|
||||
|
||||
// const supabase = getSupabase();
|
||||
|
||||
export async function POST({ request }) {
|
||||
export async function POST({ fetch, request }) {
|
||||
const { to, courseName, teacherEmail, studentFullname, orgName } = await request.json();
|
||||
// const accessToken = request.headers.get('Authorization') || '';
|
||||
console.log(
|
||||
@@ -48,7 +48,7 @@ export async function POST({ request }) {
|
||||
}
|
||||
];
|
||||
|
||||
await sendEmail(emailData);
|
||||
await sendEmail(fetch)(emailData);
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
import sendEmail from '$mail/sendEmail';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import sendEmail from '$mail/sendEmail';
|
||||
|
||||
// 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();
|
||||
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({
|
||||
success: true,
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import sendEmail from '$mail/sendEmail';
|
||||
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();
|
||||
|
||||
if (!to || !content || !orgName) {
|
||||
@@ -17,7 +17,7 @@ export async function POST({ request }) {
|
||||
}
|
||||
];
|
||||
|
||||
await sendEmail(emailData);
|
||||
await sendEmail(fetch)(emailData);
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import sendEmail from '$mail/sendEmail';
|
||||
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();
|
||||
console.log(
|
||||
'/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({
|
||||
success: true,
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import sendEmail from '$mail/sendEmail';
|
||||
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();
|
||||
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({
|
||||
success: true,
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import sendEmail from '$mail/sendEmail';
|
||||
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();
|
||||
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({
|
||||
success: true,
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
import sendEmail from '$mail/sendEmail';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import sendEmail from '$mail/sendEmail';
|
||||
|
||||
// API to send invite to teacher
|
||||
export async function POST({ request }) {
|
||||
export async function POST({ fetch, request }) {
|
||||
const body = await request.json();
|
||||
const { org, email } = body;
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function POST({ request }) {
|
||||
`
|
||||
}
|
||||
];
|
||||
await sendEmail(emailData);
|
||||
await sendEmail(fetch)(emailData);
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import sendEmail from '$mail/sendEmail';
|
||||
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();
|
||||
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({
|
||||
success: true,
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import sendEmail from '$mail/sendEmail';
|
||||
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();
|
||||
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({
|
||||
success: true,
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
|
||||
import {
|
||||
checkExercisesComplete,
|
||||
@@ -30,8 +29,6 @@
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import CourseIcon from '$lib/components/Icons/CourseIcon.svelte';
|
||||
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 { snackbar } from '$lib/components/Snackbar/store';
|
||||
import MODES from '$lib/utils/constants/mode';
|
||||
@@ -42,9 +39,9 @@
|
||||
import { COURSE_VERSION, type Lesson, type LessonCompletion } from '$lib/utils/types';
|
||||
import { Dropdown } from 'carbon-components-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 ResultOld from 'carbon-icons-svelte/lib/ResultOld.svelte';
|
||||
import { apiClient } from '$lib/utils/services/api';
|
||||
|
||||
export let data;
|
||||
|
||||
@@ -163,13 +160,9 @@
|
||||
const lessonNumber = getLessonOrder(currentLesson.id);
|
||||
const slideUrl = $lesson.materials.slide_url || '';
|
||||
|
||||
const response = await fetch(env.PUBLIC_SERVER_URL + '/downloadLesson', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
const response = await apiClient.post(
|
||||
'/course/lesson/download/pdf',
|
||||
{
|
||||
title: currentLesson.title,
|
||||
number: lessonNumber,
|
||||
orgName: $currentOrg.name,
|
||||
@@ -177,16 +170,13 @@
|
||||
slideUrl: slideUrl,
|
||||
video: lessonVideo,
|
||||
courseTitle: $course.title
|
||||
})
|
||||
});
|
||||
},
|
||||
{
|
||||
responseType: 'blob'
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
const data = await response.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);
|
||||
|
||||
let a = document.createElement('a');
|
||||
@@ -386,17 +376,6 @@
|
||||
<Edit size={24} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
|
||||
{#if $course.metadata.lessonDownload && !!env.PUBLIC_SERVER_URL}
|
||||
<PrimaryButton
|
||||
className="mr-"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={downloadLesson}
|
||||
{isLoading}
|
||||
>
|
||||
<Download size={16} />
|
||||
</PrimaryButton>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dropdown items={LANGUAGES} bind:selectedId={$lesson.locale} class="h-full" />
|
||||
|
||||
@@ -29,6 +29,7 @@ SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_SENDER=
|
||||
```
|
||||
|
||||
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_PORT=""
|
||||
SMTP_USER=""
|
||||
SMTP_SENDER=""
|
||||
SMTP_PASSWORD=""
|
||||
PUBLIC_SUPABASE_URL=""
|
||||
PUBLIC_SUPABASE_ANON_KEY=""
|
||||
|
||||
@@ -44,6 +44,7 @@ services:
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_SENDER=${SMTP_SENDER}
|
||||
|
||||
dashboard:
|
||||
build:
|
||||
|
||||
+3
-3
@@ -9,9 +9,9 @@
|
||||
"format": "prettier . --write",
|
||||
"release": "standard-version",
|
||||
"ci": "cypress run",
|
||||
"supabase:start": "pnpx supabase@1.51.1 start",
|
||||
"supabase:login": "pnpx supabase@1.51.1 login",
|
||||
"supabase:status": "pnpx supabase@1.51.1 status",
|
||||
"supabase:start": "pnpx supabase@1.51.0 start",
|
||||
"supabase:login": "pnpx supabase@1.51.0 login",
|
||||
"supabase:status": "pnpx supabase@1.51.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:start": "cd apps/dashboard && node build"
|
||||
},
|
||||
|
||||
Generated
+1788
-724
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -461,4 +461,12 @@ INSERT INTO "storage"."buckets" ("id", "name", "owner", "created_at", "updated_a
|
||||
-- Get the sequence name from supabase database
|
||||
SELECT PG_GET_SERIAL_SEQUENCE('organizationmember', 'id');
|
||||
-- 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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user