feat: end to end api type safety with redis rate limiting (#573)
* feat: support hono rpc * add wrangler file * update name of wrangler file * add wrangler as a dependency * add compatibility_flags * trigger ci * core: add health check to docker file * expose port 80 * update custom healthcheck * feat: implement openapi * install dependencies in root * debug yml * update yml * update port * update dockerfile with envs * completely move away from apiClient * rename backend to api * refactor: update server descriptions and comment out local spec saving * update lock file * use api.classroomio.com * add support for redis * fix: add missing line break in WelcomeModal.svelte * refactor: remove wrangler configuration and update Vite config for API integration
This commit is contained in:
@@ -1,18 +0,0 @@
|
||||
name: Deploy backend on push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: deploy-group # optional: ensure only one action runs at a time
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: superfly/flyctl-actions/setup-flyctl@master
|
||||
- run: cd apps/backend && flyctl deploy --remote-only
|
||||
env:
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Upload OpenAPI Spec to R2
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/api/src/**'
|
||||
- 'apps/api/scripts/**'
|
||||
- 'apps/api/package.json'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
NODE_VERSION: '20'
|
||||
PNPM_VERSION: '8'
|
||||
|
||||
jobs:
|
||||
generate-and-upload-openapi:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
ls -la && echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Upload to Cloudflare R2
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
cd apps/api
|
||||
pnpm run upload:openapi
|
||||
env:
|
||||
CLOUDFLARE_ACCESS_KEY: ${{ secrets.CLOUDFLARE_ACCESS_KEY }}
|
||||
CLOUDFLARE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_SECRET_ACCESS_KEY }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
PUBLIC_SUPABASE_URL: ${{ secrets.PUBLIC_SUPABASE_URL }}
|
||||
PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.PUBLIC_SUPABASE_ANON_KEY }}
|
||||
@@ -72,7 +72,7 @@ Here is what you need to be able to run ClassroomIO.com
|
||||
This repo is a mono repo that consists of 3 projects:
|
||||
|
||||
1. `classroomio-com`: The landing page of ClassroomIO hosted [here](https://classroomio.com)
|
||||
2. `backend`: The backend service that handles PDF, video processing, Emailing and Notifications.
|
||||
2. `api`: The api service that handles PDF, video processing, Emailing and Notifications.
|
||||
3. `dashboard`: The web application that runs the learning management system hosted [here](https://app.classroomio.com).
|
||||
4. `docs`: Official documentation of ClassroomIO hosted [here](https://classroomio.com/docs)
|
||||
|
||||
@@ -118,7 +118,7 @@ This repo is a mono repo that consists of 3 projects:
|
||||
|
||||
4. Set up your `.env` file
|
||||
|
||||
- Go to `apps/classroomio-com`, `apps/dashboard` and `apps/backend`.
|
||||
- Go to `apps/classroomio-com`, `apps/dashboard` and `apps/api`.
|
||||
- Duplicate the `.env.example` file and rename it to `.env`
|
||||
- Populate your .env files with the neccessary variables
|
||||
|
||||
@@ -173,14 +173,14 @@ To get the environmental variables for supabase continue to step(5)
|
||||
8. All projects should start running
|
||||
|
||||
- `classroomio-com`: [http://localhost:5174](http://localhost:5174)
|
||||
- `backend`: [http://localhost:3002](http://localhost:3002)
|
||||
- `api`: [http://localhost:3002](http://localhost:3002)
|
||||
- `dashboard`: [http://localhost:5173](http://localhost:5173)
|
||||
- `docs`: [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
9. Running a specific project
|
||||
|
||||
- **classroomio-com**: `pnpm dev --filter=classroomio-com`
|
||||
- **backend**: `pnpm dev --filter=backend`
|
||||
- **api**: `pnpm dev --filter=api`
|
||||
- **dashboard**: `pnpm dev --filter=dashboard`
|
||||
- **docs**: `pnpm dev --filter=docs`
|
||||
|
||||
|
||||
@@ -18,3 +18,5 @@ SMTP_PORT=
|
||||
|
||||
# Sentry DNS
|
||||
SENTRY_DNS=
|
||||
|
||||
OPENAPI_URL=
|
||||
+2
-31
@@ -1,33 +1,4 @@
|
||||
# prod
|
||||
dist/
|
||||
|
||||
# dev
|
||||
.yarn/
|
||||
!.yarn/releases
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/*.code-snippets
|
||||
.idea/workspace.xml
|
||||
.idea/usage.statistics.xml
|
||||
.idea/shelf
|
||||
|
||||
# deps
|
||||
node_modules/
|
||||
.wrangler
|
||||
|
||||
# env
|
||||
node_modules
|
||||
.env
|
||||
.env.production
|
||||
.dev.vars
|
||||
|
||||
# logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
dist
|
||||
@@ -13,12 +13,18 @@ ARG SMTP_USER
|
||||
ARG SMTP_PASSWORD
|
||||
ARG SMTP_PORT
|
||||
ARG SMTP_SENDER
|
||||
ARG ZOHO_TOKEN
|
||||
ARG CLOUDFLARE_RENDERING_API_KEY
|
||||
ARG OPENAPI_URL
|
||||
|
||||
RUN mkdir /app
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
# Install pnpm and healthcheck tools
|
||||
RUN npm install -g pnpm && \
|
||||
apt-get update && \
|
||||
apt-get install -y curl wget && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install chrome stable from sources, then remove it. Why? The
|
||||
# npm install of `puppeteer` brings its own bundle chromium build,
|
||||
@@ -58,14 +64,22 @@ ENV NODE_ENV=production \
|
||||
CLOUDFLARE_SECRET_ACCESS_KEY=$CLOUDFLARE_SECRET_ACCESS_KEY \
|
||||
CLOUDFLARE_ACCOUNT_ID=$CLOUDFLARE_ACCOUNT_ID \
|
||||
CLOUDFLARE_BUCKET_ID=$CLOUDFLARE_BUCKET_ID \
|
||||
CLOUDFLARE_RENDERING_API_KEY=$CLOUDFLARE_RENDERING_API_KEY \
|
||||
SMTP_HOST=$SMTP_HOST \
|
||||
SMTP_USER=$SMTP_USER \
|
||||
SMTP_PASSWORD=$SMTP_PASSWORD \
|
||||
SMTP_PORT=$SMTP_PORT \
|
||||
SMTP_SENDER=$SMTP_SENDER \
|
||||
ZOHO_TOKEN=$ZOHO_TOKEN \
|
||||
OPENAPI_URL=$OPENAPI_URL \
|
||||
PORT=3002
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
EXPOSE 3002
|
||||
|
||||
# Add healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3002 || exit 1
|
||||
|
||||
CMD ["pnpm", "start"]
|
||||
+9
-7
@@ -1,8 +1,10 @@
|
||||
```
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
# ClassroomIO Backend
|
||||
|
||||
```
|
||||
npm run deploy
|
||||
```
|
||||
## Supported Features
|
||||
|
||||
1. Certificate download for students.
|
||||
2. Uploading videos to cloudflare (for storage) and Muse.ai (for transcription)
|
||||
|
||||
## Debugging Fly.io
|
||||
|
||||
View logs from fly.io in real time
|
||||
|
||||
+71
-6
@@ -1,14 +1,79 @@
|
||||
{
|
||||
"name": "api",
|
||||
"name": "@cio/api",
|
||||
"version": "0.0.1",
|
||||
"description": "ClassroomIO Backend for long processes",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy --minify"
|
||||
"build": "tsc && tsc-alias",
|
||||
"dev": "pnpm --filter @cio/api --parallel \"/^dev:.*/\"",
|
||||
"dev:server": "tsx watch src/index.ts",
|
||||
"dev:tsc": "tsc -p tsconfig.json --watch",
|
||||
"deploy": "wrangler deploy",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest NODE_ENV=test",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"upload:openapi": "tsx scripts/upload-openapi-spec.ts"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/classroomio/classroomio.git"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/classroomio/classroomio/issues"
|
||||
},
|
||||
"homepage": "https://github.com/classroomio/classroomio#readme",
|
||||
"exports": {
|
||||
"./rpc-types": {
|
||||
"types": "./dist/rpc-types.d.ts",
|
||||
"default": "./dist/rpc-types.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.6.16"
|
||||
"@aws-sdk/client-s3": "^3.388.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.675.0",
|
||||
"@hono/node-server": "^1.8.2",
|
||||
"@hono/standard-validator": "^0.1.5",
|
||||
"@hono/zod-validator": "^0.7.2",
|
||||
"@scalar/hono-api-reference": "^0.9.18",
|
||||
"@sentry/node": "^7.110.1",
|
||||
"@sentry/profiling-node": "^7.110.1",
|
||||
"@supabase/supabase-js": "^2.32.0",
|
||||
"axios": "^1.4.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"hono": "^4.9.7",
|
||||
"hono-openapi": "^1.0.8",
|
||||
"hono-rate-limiter": "^0.4.2",
|
||||
"ioredis": "^5.7.0",
|
||||
"katex": "^0.16.10",
|
||||
"marked": "^7.0.3",
|
||||
"nanoid": "^5.1.5",
|
||||
"nodemailer": "^6.9.5",
|
||||
"zeptomail": "^6.0.0",
|
||||
"zod": "^4.1.8",
|
||||
"zod-openapi": "^5.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250109.0",
|
||||
"wrangler": "^3.101.0"
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/node": "^20.11.19",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@typescript-eslint/eslint-plugin": "7.0.1",
|
||||
"@typescript-eslint/parser": "7.0.1",
|
||||
"@vitest/coverage-v8": "^1.2.2",
|
||||
"eslint": "^9.31.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",
|
||||
"wrangler": "4.21.x"
|
||||
},
|
||||
"_moduleAliases": {
|
||||
"$src": "src"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# OpenAPI Spec Generator Script
|
||||
|
||||
This script generates the OpenAPI specification from your Hono application and optionally uploads it to a Cloudflare R2 bucket.
|
||||
|
||||
## Usage
|
||||
|
||||
### Generate and Upload OpenAPI spec
|
||||
|
||||
```bash
|
||||
pnpm run upload:openapi
|
||||
```
|
||||
|
||||
### Direct script usage
|
||||
```bash
|
||||
tsx scripts/upload-openapi-spec.ts
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Generate spec locally only
|
||||
pnpm upload:openapi
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
For R2 upload functionality, set these environment variables:
|
||||
|
||||
```bash
|
||||
CLOUDFLARE_ACCESS_KEY=your_access_key
|
||||
CLOUDFLARE_SECRET_ACCESS_KEY=your_secret_key
|
||||
CLOUDFLARE_ACCOUNT_ID=your_account_id
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
The script will:
|
||||
|
||||
1. **Generate** the OpenAPI specification from your Hono app routes
|
||||
2. **Save locally** to `dist/openapi/openapi.json`
|
||||
3. **Upload to R2** (if credentials provided):
|
||||
- `openapi/openapi-YYYY-MM-DD.json` (dated version)
|
||||
- `openapi/openapi-latest.json` (latest version)
|
||||
|
||||
## GitHub Action Integration
|
||||
|
||||
This script is integrated with a GitHub Action that automatically generates and uploads the OpenAPI spec on code changes.
|
||||
|
||||
### Automatic Workflow
|
||||
|
||||
The GitHub Action runs on:
|
||||
- **Push to main** - Automatically uploads to R2
|
||||
- **Pull Requests** - Generates spec for review (no upload)
|
||||
- **Manual dispatch** - Allows manual triggering with custom parameters
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Required secrets:**
|
||||
- `CLOUDFLARE_ACCESS_KEY`
|
||||
- `CLOUDFLARE_SECRET_ACCESS_KEY`
|
||||
- `CLOUDFLARE_ACCOUNT_ID`
|
||||
- `PUBLIC_SUPABASE_URL`
|
||||
- `PUBLIC_SUPABASE_ANON_KEY`
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Automatic OpenAPI spec generation from Hono routes
|
||||
- ✅ Local file generation for development
|
||||
- ✅ Cloudflare R2 upload with versioning
|
||||
- ✅ GitHub Action integration
|
||||
- ✅ Environment-specific deployments
|
||||
- ✅ PR previews and artifacts
|
||||
- ✅ Error handling and logging
|
||||
- ✅ CLI help with `--help` flag
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
|
||||
import { app } from '../src/app';
|
||||
import { env } from '../src/config/env';
|
||||
import { generateSpecs } from 'hono-openapi';
|
||||
import { join } from 'path';
|
||||
|
||||
interface UploadOptions {
|
||||
key: string;
|
||||
content: string;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
class OpenAPISpecGenerator {
|
||||
private s3Client: S3Client | null = null;
|
||||
|
||||
constructor() {
|
||||
this.initializeS3Client();
|
||||
}
|
||||
|
||||
private initializeS3Client() {
|
||||
if (
|
||||
env.CLOUDFLARE_ACCESS_KEY &&
|
||||
env.CLOUDFLARE_SECRET_ACCESS_KEY &&
|
||||
env.CLOUDFLARE_ACCOUNT_ID
|
||||
) {
|
||||
this.s3Client = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: env.CLOUDFLARE_ACCESS_KEY,
|
||||
secretAccessKey: env.CLOUDFLARE_SECRET_ACCESS_KEY
|
||||
}
|
||||
});
|
||||
console.log('✅ S3 client initialized for Cloudflare R2');
|
||||
} else {
|
||||
console.warn('⚠️ Cloudflare R2 credentials not found. Will only generate local spec file.');
|
||||
}
|
||||
}
|
||||
|
||||
async generateSpec(): Promise<string> {
|
||||
try {
|
||||
console.log('🔄 Generating OpenAPI specification...');
|
||||
|
||||
// Generate the OpenAPI spec from the Hono app
|
||||
const spec = await generateSpecs(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: 'ClassroomIO API',
|
||||
version: '1.0.0',
|
||||
description: 'Manage your organization on classroomio via the API',
|
||||
contact: {
|
||||
name: 'ClassroomIO',
|
||||
url: 'https://classroomio.com'
|
||||
}
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: 'https://api.classroomio.com',
|
||||
description: 'Production environment'
|
||||
},
|
||||
{
|
||||
url: 'http://localhost:3002',
|
||||
description: 'Development environment'
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const specString = JSON.stringify(spec, null, 2);
|
||||
console.log('✅ OpenAPI specification generated successfully');
|
||||
|
||||
return specString;
|
||||
} catch (error) {
|
||||
console.error('❌ Error generating OpenAPI spec:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async saveLocalSpec(spec: string): Promise<string> {
|
||||
try {
|
||||
const outputDir = join(process.cwd(), 'dist', 'openapi');
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const filePath = join(outputDir, 'openapi.json');
|
||||
writeFileSync(filePath, spec, 'utf8');
|
||||
|
||||
console.log(`✅ OpenAPI spec saved locally: ${filePath}`);
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
console.error('❌ Error saving local spec:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async uploadToR2(options: UploadOptions): Promise<void> {
|
||||
if (!this.s3Client) {
|
||||
throw new Error('S3 client not initialized. Check your Cloudflare R2 credentials.');
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🔄 Uploading to R2 bucket`);
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: 'api',
|
||||
Key: options.key,
|
||||
Body: options.content,
|
||||
ContentType: options.contentType || 'application/json',
|
||||
CacheControl: 'public, max-age=3600' // Cache for 1 hour
|
||||
});
|
||||
|
||||
await this.s3Client.send(command);
|
||||
console.log(`✅ Successfully uploaded to R2`);
|
||||
} catch (error) {
|
||||
console.error('❌ Error uploading to R2:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async generateAndUpload(): Promise<void> {
|
||||
try {
|
||||
const spec = await this.generateSpec();
|
||||
|
||||
// await this.saveLocalSpec(spec);
|
||||
|
||||
if (this.s3Client) {
|
||||
const uploadKey = `openapi/openapi-${new Date().toISOString().split('T')[0]}.json`;
|
||||
await this.uploadToR2({
|
||||
key: uploadKey,
|
||||
content: spec,
|
||||
contentType: 'application/json'
|
||||
});
|
||||
|
||||
// Also upload as latest version
|
||||
await this.uploadToR2({
|
||||
key: 'openapi/openapi-latest.json',
|
||||
content: spec,
|
||||
contentType: 'application/json'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('🎉 OpenAPI spec generation and upload completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to generate and upload OpenAPI spec:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`
|
||||
Usage: tsx scripts/upload-openapi-spec.ts
|
||||
|
||||
Examples:
|
||||
tsx scripts/upload-openapi-spec.ts
|
||||
|
||||
Environment Variables Required for R2 Upload:
|
||||
CLOUDFLARE_ACCESS_KEY
|
||||
CLOUDFLARE_SECRET_ACCESS_KEY
|
||||
CLOUDFLARE_ACCOUNT_ID
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
const generator = new OpenAPISpecGenerator();
|
||||
await generator.generateAndUpload();
|
||||
}
|
||||
|
||||
// Run the script if called directly
|
||||
if (process.argv[1] && process.argv[1].endsWith('upload-openapi-spec.ts')) {
|
||||
main()
|
||||
.then(() => {
|
||||
console.log('Script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export { OpenAPISpecGenerator };
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { courseRouter } from '$src/routes/course/course';
|
||||
import { logger } from 'hono/logger';
|
||||
import { mailRouter } from '$src/routes/mail';
|
||||
import { prettyJSON } from 'hono/pretty-json';
|
||||
import { rateLimiterMiddleware } from '$src/middlewares/rate-limiter';
|
||||
import { secureHeaders } from 'hono/secure-headers';
|
||||
|
||||
// Create Hono app with chaining for RPC support
|
||||
export const app = new Hono()
|
||||
// Middleware
|
||||
.use('*', logger())
|
||||
.use('*', prettyJSON())
|
||||
.use('*', secureHeaders())
|
||||
.use(
|
||||
'*',
|
||||
cors({
|
||||
origin: '*',
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
|
||||
maxAge: 600,
|
||||
credentials: true
|
||||
})
|
||||
)
|
||||
.use('*', rateLimiterMiddleware)
|
||||
|
||||
// Routes
|
||||
.get('/', (c) =>
|
||||
c.json({
|
||||
message: '"Welcome to Classroomio.com API - docs are at https://api.classroomio.com/docs"'
|
||||
})
|
||||
)
|
||||
.route('/course', courseRouter)
|
||||
.route('/mail', mailRouter)
|
||||
|
||||
// Error handling
|
||||
.onError((err, c) => {
|
||||
console.error('Error:', err);
|
||||
return c.json({ error: 'Internal Server Error' }, 500);
|
||||
});
|
||||
@@ -1,20 +1,23 @@
|
||||
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(),
|
||||
CLOUDFLARE_BUCKET_DOMAIN: z.string().optional(),
|
||||
CLOUDFLARE_RENDERING_API_KEY: z.string().optional(),
|
||||
CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
OPENAPI_URL: z.string().optional(),
|
||||
PORT: z.string().optional(),
|
||||
PUBLIC_SUPABASE_ANON_KEY: z.string(),
|
||||
ZOHO_TOKEN: z.string().optional(),
|
||||
PUBLIC_SUPABASE_URL: z.string(),
|
||||
REDIS_URL: 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()
|
||||
SMTP_PORT: z.string().optional(),
|
||||
SMTP_SENDER: z.string().optional(),
|
||||
SMTP_USER: z.string().optional(),
|
||||
ZOHO_TOKEN: z.string().optional()
|
||||
});
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
@@ -1,16 +1,20 @@
|
||||
import { env } from '$src/config/env';
|
||||
|
||||
export * from './rate-limiter';
|
||||
|
||||
const {
|
||||
CLOUDFLARE_BUCKET_DOMAIN,
|
||||
CLOUDFLARE_ACCESS_KEY,
|
||||
CLOUDFLARE_SECRET_ACCESS_KEY,
|
||||
CLOUDFLARE_ACCOUNT_ID
|
||||
CLOUDFLARE_ACCOUNT_ID,
|
||||
CLOUDFLARE_RENDERING_API_KEY
|
||||
} = env;
|
||||
|
||||
export const CLOUDFLARE = {
|
||||
CONFIGS: {
|
||||
BUCKET_DOMAIN: CLOUDFLARE_BUCKET_DOMAIN!,
|
||||
ACCESS_KEY: CLOUDFLARE_ACCESS_KEY!,
|
||||
RENDERING_API_KEY: CLOUDFLARE_RENDERING_API_KEY!,
|
||||
SECRET_ACCESS_KEY: CLOUDFLARE_SECRET_ACCESS_KEY!,
|
||||
ACCOUNT_ID: CLOUDFLARE_ACCOUNT_ID!
|
||||
},
|
||||
@@ -20,6 +24,6 @@ export const CLOUDFLARE = {
|
||||
? `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
|
||||
DOWNLOAD_EXPIRATION_TIME: 60 * 60 // 1 hour
|
||||
}
|
||||
} as const;
|
||||
@@ -0,0 +1,32 @@
|
||||
// Default time window for rate limiting (1 minute in milliseconds)
|
||||
export const DEFAULT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
export const DEFAULT_MAX_REQUESTS = 100;
|
||||
|
||||
export const RATE_LIMIT_KEY_PREFIX = 'rate_limit:';
|
||||
|
||||
export const DEFAULT_RATE_LIMITER_OPTIONS = {
|
||||
windowMs: DEFAULT_WINDOW_MS,
|
||||
maxRequests: DEFAULT_MAX_REQUESTS,
|
||||
skipSuccessfulRequests: false,
|
||||
skipFailedRequests: false,
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
} as const;
|
||||
|
||||
export const HTTP_STATUS = {
|
||||
TOO_MANY_REQUESTS: 429
|
||||
} as const;
|
||||
|
||||
export const ERROR_MESSAGES = {
|
||||
RATE_LIMIT_EXCEEDED: 'Too Many Requests',
|
||||
REDIS_PIPELINE_FAILED: 'Redis pipeline execution failed'
|
||||
} as const;
|
||||
|
||||
export const RATE_LIMIT_HEADERS = {
|
||||
LIMIT: 'X-RateLimit-Limit',
|
||||
REMAINING: 'X-RateLimit-Remaining',
|
||||
RESET: 'X-RateLimit-Reset',
|
||||
RETRY_AFTER: 'Retry-After'
|
||||
} as const;
|
||||
+19
-6
@@ -1,9 +1,22 @@
|
||||
import { Hono } from 'hono'
|
||||
import 'dotenv/config';
|
||||
|
||||
const app = new Hono()
|
||||
import { app } from '$src/app';
|
||||
import { configureOpenAPI } from '$src/utils/openapi';
|
||||
import { env } from '$src/config/env';
|
||||
import { serve } from '@hono/node-server';
|
||||
import { showRoutes } from 'hono/dev';
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.text('Hello Hono!')
|
||||
})
|
||||
// Start server
|
||||
const port = env.PORT ? parseInt(env.PORT) : 3002;
|
||||
|
||||
export default app
|
||||
function startServer() {
|
||||
console.log('Starting server...');
|
||||
|
||||
serve({ fetch: app.fetch, port });
|
||||
|
||||
showRoutes(app, { colorize: true });
|
||||
}
|
||||
|
||||
configureOpenAPI(app);
|
||||
|
||||
startServer();
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
DEFAULT_RATE_LIMITER_OPTIONS,
|
||||
ERROR_MESSAGES,
|
||||
HTTP_STATUS,
|
||||
RATE_LIMIT_HEADERS
|
||||
} from '$src/constants/rate-limiter';
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { rateLimiter } from '$src/utils/redis/limiter';
|
||||
import { userKeyGenerator } from '../utils/redis/key-generators';
|
||||
|
||||
export interface RateLimiterOptions {
|
||||
windowMs?: number;
|
||||
maxRequests?: number;
|
||||
keyGenerator?: (c: any) => string;
|
||||
skipSuccessfulRequests?: boolean;
|
||||
skipFailedRequests?: boolean;
|
||||
message?: string;
|
||||
standardHeaders?: boolean;
|
||||
legacyHeaders?: boolean;
|
||||
}
|
||||
|
||||
const defaultOptions: Required<RateLimiterOptions> = {
|
||||
...DEFAULT_RATE_LIMITER_OPTIONS,
|
||||
keyGenerator: userKeyGenerator
|
||||
};
|
||||
|
||||
export const createRateLimiter = (options: RateLimiterOptions = {}): MiddlewareHandler => {
|
||||
const opts = { ...defaultOptions, ...options };
|
||||
|
||||
return async (c, next) => {
|
||||
try {
|
||||
// Generate rate limit key
|
||||
const key = opts.keyGenerator(c);
|
||||
|
||||
// Check rate limit
|
||||
const result = await rateLimiter.isAllowed(key);
|
||||
|
||||
// Set rate limit headers
|
||||
if (opts.standardHeaders) {
|
||||
c.header(RATE_LIMIT_HEADERS.LIMIT, opts.maxRequests.toString());
|
||||
c.header(RATE_LIMIT_HEADERS.REMAINING, result.remaining.toString());
|
||||
c.header(RATE_LIMIT_HEADERS.RESET, new Date(result.resetTime).toISOString());
|
||||
}
|
||||
|
||||
if (opts.legacyHeaders) {
|
||||
c.header(RATE_LIMIT_HEADERS.LIMIT, opts.maxRequests.toString());
|
||||
c.header(RATE_LIMIT_HEADERS.REMAINING, result.remaining.toString());
|
||||
c.header(RATE_LIMIT_HEADERS.RESET, Math.ceil(result.resetTime / 1000).toString());
|
||||
}
|
||||
|
||||
if (!result.allowed) {
|
||||
const retryAfter = Math.ceil((result.resetTime - Date.now()) / 1000);
|
||||
|
||||
if (opts.standardHeaders) {
|
||||
c.header(RATE_LIMIT_HEADERS.RETRY_AFTER, retryAfter.toString());
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
error: ERROR_MESSAGES.RATE_LIMIT_EXCEEDED,
|
||||
message: opts.message,
|
||||
retryAfter
|
||||
},
|
||||
HTTP_STATUS.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
await next();
|
||||
} catch (error) {
|
||||
console.error('Rate limiter error:', error);
|
||||
// If Redis is down, allow the request but log the error
|
||||
await next();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Default rate limiter middleware with standard configuration
|
||||
export const rateLimiterMiddleware = createRateLimiter();
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ZCertificateDownload, ZCourseDownloadContent } from '$src/types/course';
|
||||
|
||||
import { Hono } from 'hono';
|
||||
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 { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const courseRouter = new Hono()
|
||||
.post('/download/certificate', zValidator('json', ZCertificateDownload), async (c) => {
|
||||
const validatedData = c.req.valid('json');
|
||||
|
||||
const result = await generateCertificate(validatedData);
|
||||
|
||||
c.header('Content-Type', 'application/pdf');
|
||||
c.header(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="certificate-${validatedData.courseName}.pdf"`
|
||||
);
|
||||
|
||||
return c.body(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(result);
|
||||
controller.close();
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
.post('/download/content', zValidator('json', ZCourseDownloadContent), async (c) => {
|
||||
const validatedData = c.req.valid('json');
|
||||
|
||||
const pdfBuffer = await generateCoursePdf(validatedData);
|
||||
|
||||
c.header('Content-Type', 'application/pdf');
|
||||
|
||||
return c.body(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(pdfBuffer);
|
||||
controller.close();
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
.route('/katex', katexRouter)
|
||||
.route('/lesson', lessonRouter)
|
||||
.route('/presign', presignRouter);
|
||||
@@ -3,9 +3,7 @@ import { Context, Hono } from 'hono';
|
||||
import katex from 'katex';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const katexRouter = new Hono();
|
||||
|
||||
katexRouter.get('/', async (c: Context) => {
|
||||
export const katexRouter = new Hono().get('/', async (c: Context) => {
|
||||
try {
|
||||
const original = c.req.raw.url;
|
||||
|
||||
@@ -23,7 +21,7 @@ katexRouter.get('/', async (c: Context) => {
|
||||
{
|
||||
success: false,
|
||||
error: 'Validation error',
|
||||
details: error.errors
|
||||
details: error.message
|
||||
},
|
||||
400
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Hono } from 'hono';
|
||||
import { ZLessonDownloadContent } from '$src/types/course/lesson';
|
||||
import { generateLessonPdf } from '$src/utils/lesson';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const lessonRouter = new Hono().post(
|
||||
'/download/pdf',
|
||||
zValidator('json', ZLessonDownloadContent),
|
||||
async (c) => {
|
||||
const validatedData = c.req.valid('json');
|
||||
|
||||
const pdfBuffer = await generateLessonPdf(validatedData);
|
||||
|
||||
c.header('Content-Type', 'application/pdf');
|
||||
return c.body(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(pdfBuffer);
|
||||
controller.close();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,253 @@
|
||||
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import {
|
||||
ZCourseDocumentPresignUrlUpload,
|
||||
ZCourseDownloadPresignedUrl,
|
||||
ZCoursePresignUrlUpload
|
||||
} from '$src/types/course';
|
||||
import { describeRoute, validator } from 'hono-openapi';
|
||||
|
||||
import { BUCKET_NAME } from '$src/constants/upload';
|
||||
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';
|
||||
|
||||
// Response schemas for OpenAPI documentation
|
||||
const PresignUploadResponse = {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
success: { type: 'boolean' as const },
|
||||
url: { type: 'string' as const },
|
||||
fileKey: { type: 'string' as const },
|
||||
message: { type: 'string' as const }
|
||||
},
|
||||
required: ['success', 'url', 'fileKey', 'message']
|
||||
};
|
||||
|
||||
const PresignDownloadResponse = {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
success: { type: 'boolean' as const },
|
||||
urls: {
|
||||
type: 'object' as const,
|
||||
additionalProperties: { type: 'string' as const }
|
||||
},
|
||||
message: { type: 'string' as const }
|
||||
},
|
||||
required: ['success', 'urls', 'message']
|
||||
};
|
||||
|
||||
export const presignRouter = new Hono()
|
||||
.post(
|
||||
'/video/upload',
|
||||
authMiddleware,
|
||||
describeRoute({
|
||||
description: 'Generate a pre-signed URL for video upload',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Pre-signed URL generated successfully',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: PresignUploadResponse
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid request body'
|
||||
},
|
||||
401: {
|
||||
description: 'Unauthorized'
|
||||
}
|
||||
},
|
||||
tags: ['Presign']
|
||||
}),
|
||||
validator('json', ZCoursePresignUrlUpload),
|
||||
async (c) => {
|
||||
const body = c.req.valid('json');
|
||||
|
||||
const { fileName, fileType } = body;
|
||||
const fileKey = generateFileKey(fileName);
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: BUCKET_NAME.VIDEOS,
|
||||
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'
|
||||
});
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/document/upload',
|
||||
authMiddleware,
|
||||
describeRoute({
|
||||
description: 'Generate a pre-signed URL for document upload',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Document pre-signed URL generated successfully',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: PresignUploadResponse
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid request body'
|
||||
},
|
||||
401: {
|
||||
description: 'Unauthorized'
|
||||
}
|
||||
},
|
||||
tags: ['Presign']
|
||||
}),
|
||||
validator('json', ZCourseDocumentPresignUrlUpload),
|
||||
async (c) => {
|
||||
const body = c.req.valid('json');
|
||||
|
||||
const { fileName, fileType } = body;
|
||||
const fileKey = generateFileKey(fileName);
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: BUCKET_NAME.DOCUMENTS,
|
||||
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: 'Document pre-signed URL generated successfully'
|
||||
});
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/video/download',
|
||||
authMiddleware,
|
||||
describeRoute({
|
||||
description: 'Generate pre-signed URLs for video download',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Video URLs retrieved successfully',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: PresignDownloadResponse
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid request body'
|
||||
},
|
||||
401: {
|
||||
description: 'Unauthorized'
|
||||
}
|
||||
},
|
||||
tags: ['Presign']
|
||||
}),
|
||||
validator('json', ZCourseDownloadPresignedUrl),
|
||||
async (c) => {
|
||||
const body = c.req.valid('json');
|
||||
|
||||
const { keys } = body;
|
||||
|
||||
const signedUrls: Record<string, string> = {};
|
||||
|
||||
const urlPromises = keys.map(async (key) => {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: BUCKET_NAME.VIDEOS,
|
||||
Key: key
|
||||
}) as GetSignedUrlParameters[1];
|
||||
|
||||
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
|
||||
expiresIn: CLOUDFLARE.R2.DOWNLOAD_EXPIRATION_TIME
|
||||
});
|
||||
|
||||
return { key, presignedUrl };
|
||||
});
|
||||
|
||||
const results = await Promise.all(urlPromises);
|
||||
|
||||
results.forEach(({ key, presignedUrl }) => {
|
||||
signedUrls[key] = presignedUrl;
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
urls: signedUrls,
|
||||
message: 'Video URLs retrieved successfully'
|
||||
});
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/document/download',
|
||||
authMiddleware,
|
||||
describeRoute({
|
||||
description: 'Generate pre-signed URLs for document download',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Document URLs retrieved successfully',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: PresignDownloadResponse
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
description: 'Invalid request body'
|
||||
},
|
||||
401: {
|
||||
description: 'Unauthorized'
|
||||
}
|
||||
},
|
||||
tags: ['Presign']
|
||||
}),
|
||||
validator('json', ZCourseDownloadPresignedUrl),
|
||||
async (c) => {
|
||||
const body = c.req.valid('json');
|
||||
|
||||
const { keys } = body;
|
||||
|
||||
const signedUrls: Record<string, string> = {};
|
||||
|
||||
const urlPromises = keys.map(async (key) => {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: BUCKET_NAME.DOCUMENTS,
|
||||
Key: key
|
||||
}) as GetSignedUrlParameters[1];
|
||||
|
||||
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
|
||||
expiresIn: CLOUDFLARE.R2.DOWNLOAD_EXPIRATION_TIME
|
||||
});
|
||||
|
||||
return { key, presignedUrl };
|
||||
});
|
||||
|
||||
const results = await Promise.all(urlPromises);
|
||||
|
||||
results.forEach(({ key, presignedUrl }) => {
|
||||
signedUrls[key] = presignedUrl;
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
urls: signedUrls,
|
||||
message: 'Document URLs retrieved successfully'
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,16 +1,15 @@
|
||||
import { Context, Hono } from 'hono';
|
||||
import { sendWithNodemailer, sendWithZoho } from '$src/services/mail';
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { ZSendEmailValidation } from '$src/types/mail';
|
||||
import { env } from '$src/config/env';
|
||||
import { z } from 'zod';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const mailRouter = new Hono();
|
||||
|
||||
mailRouter.post('/send', async (c: Context) => {
|
||||
try {
|
||||
const data = await c.req.json();
|
||||
const validatedData = ZSendEmailValidation.parse(data);
|
||||
export const mailRouter = new Hono().post(
|
||||
'/send',
|
||||
zValidator('json', ZSendEmailValidation),
|
||||
async (c) => {
|
||||
const validatedData = c.req.valid('json');
|
||||
|
||||
const results = await Promise.all(
|
||||
validatedData.map(async (emailData) => {
|
||||
@@ -45,26 +44,5 @@ mailRouter.post('/send', async (c: Context) => {
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
});
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
import { app } from './app';
|
||||
import { hc } from 'hono/client';
|
||||
import type { InferRequestType, InferResponseType } from 'hono/client';
|
||||
|
||||
export type Client = ReturnType<typeof hc<typeof app>>;
|
||||
|
||||
export const hcWithType = (...args: Parameters<typeof hc>): Client => hc<typeof app>(...args);
|
||||
|
||||
export type { InferRequestType, InferResponseType };
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
ALLOWED_CONTENT_TYPES,
|
||||
ALLOWED_DOCUMENT_TYPES,
|
||||
MAX_FILE_SIZE
|
||||
} from '$src/constants/upload';
|
||||
import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '$src/constants/upload';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -28,7 +24,7 @@ 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: () => ({
|
||||
error: () => ({
|
||||
message: `Invalid content type. Allowed types: ${ALLOWED_CONTENT_TYPES.join(', ')}`
|
||||
})
|
||||
})
|
||||
@@ -39,7 +35,7 @@ export type TCoursePresignUrlUpload = z.infer<typeof ZCoursePresignUrlUpload>;
|
||||
export const ZCourseDocumentPresignUrlUpload = z.object({
|
||||
fileName: z.string().min(1, 'File name is required'),
|
||||
fileType: z.enum(ALLOWED_DOCUMENT_TYPES, {
|
||||
errorMap: () => ({
|
||||
error: () => ({
|
||||
message: `Invalid content type. Allowed types: ${ALLOWED_DOCUMENT_TYPES.join(', ')}`
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { User } from '@supabase/supabase-js';
|
||||
import { getSupabase } from '../supabase.js';
|
||||
import { getSupabase } from '../supabase';
|
||||
|
||||
const supabase = getSupabase();
|
||||
|
||||
@@ -9,7 +9,7 @@ export const getCloudflarePdfBuffer = async (html: string, styles?: string) => {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${CLOUDFLARE.CONFIGS.ACCESS_KEY}`
|
||||
Authorization: `Bearer ${CLOUDFLARE.CONFIGS.RENDERING_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
html: html,
|
||||
@@ -19,7 +19,8 @@ export const getCloudflarePdfBuffer = async (html: string, styles?: string) => {
|
||||
);
|
||||
|
||||
console.log('PDF response status:', pdfResponse.status);
|
||||
return pdfResponse;
|
||||
const arrayBuffer = await pdfResponse.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF:', error);
|
||||
throw new Error(error instanceof Error ? error.message : 'Failed to generate PDF');
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TCourseDownloadContent } from '$src/types/course';
|
||||
import { getCloudflarePdfBuffer } from '$src/utils/cloudflare';
|
||||
import { marked } from 'marked';
|
||||
|
||||
function getHtmlTemplate(body: string): string {
|
||||
@@ -159,7 +160,7 @@ async function generateSinglePdfFromHtml(
|
||||
courseTitle: string,
|
||||
orgTheme: string
|
||||
): Promise<Buffer> {
|
||||
throw 'Download disabled';
|
||||
return await getCloudflarePdfBuffer(htmlContent);
|
||||
}
|
||||
|
||||
export async function generateCoursePdf(params: TCourseDownloadContent): Promise<Buffer> {
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Hono } from 'hono';
|
||||
import { Scalar } from '@scalar/hono-api-reference';
|
||||
import { env } from '$src/config/env';
|
||||
|
||||
export function configureOpenAPI(app: Hono) {
|
||||
if (env.OPENAPI_URL) {
|
||||
app.get('/docs', Scalar({ url: env.OPENAPI_URL, theme: 'none' }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Key generation utilities for rate limiting
|
||||
* These functions help create consistent and secure rate limit keys
|
||||
*/
|
||||
|
||||
import type { Context } from 'hono';
|
||||
import { supabase } from '$src/utils/supabase';
|
||||
|
||||
/**
|
||||
* Extract user ID from JWT token
|
||||
* In a real implementation, you'd decode and verify the JWT
|
||||
*/
|
||||
export const extractUserIdFromToken = async (token: string): Promise<string | null> => {
|
||||
try {
|
||||
const { data } = await supabase.auth.getUser(token);
|
||||
return data.user?.id || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract real client IP address considering various proxy headers
|
||||
*/
|
||||
export const extractClientIp = (c: Context): string => {
|
||||
// Priority order (most trusted first):
|
||||
// 1. CF-Connecting-IP (Cloudflare)
|
||||
// 2. X-Real-IP (nginx)
|
||||
// 3. X-Forwarded-For (standard, but can be spoofed)
|
||||
// 4. Connection IP (least reliable)
|
||||
|
||||
const cfConnectingIp = c.req.header('cf-connecting-ip');
|
||||
const realIp = c.req.header('x-real-ip');
|
||||
const forwardedFor = c.req.header('x-forwarded-for');
|
||||
|
||||
if (cfConnectingIp) {
|
||||
return cfConnectingIp;
|
||||
}
|
||||
|
||||
if (realIp) {
|
||||
return realIp;
|
||||
}
|
||||
|
||||
if (forwardedFor) {
|
||||
// x-forwarded-for can contain multiple IPs: "client, proxy1, proxy2"
|
||||
// The first IP is usually the original client
|
||||
return forwardedFor.split(',')[0].trim();
|
||||
}
|
||||
|
||||
// Fallback - this might be a proxy IP
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate rate limit key for authenticated users
|
||||
*/
|
||||
export const userKeyGenerator = (c: Context): string => {
|
||||
const authHeader = c.req.header('authorization');
|
||||
|
||||
if (authHeader) {
|
||||
const token = authHeader.split(' ')[1];
|
||||
if (token && token !== 'null' && token !== 'undefined') {
|
||||
const userId = extractUserIdFromToken(token);
|
||||
if (userId) {
|
||||
return `user:${userId}`;
|
||||
}
|
||||
// Fallback to token if user ID extraction fails
|
||||
return `token:${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
// No valid auth, fall back to IP
|
||||
return `ip:${extractClientIp(c)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate rate limit key for API keys
|
||||
*/
|
||||
export const apiKeyGenerator = (c: Context): string => {
|
||||
const apiKey = c.req.header('X-API-Key');
|
||||
|
||||
if (apiKey) {
|
||||
return `api:${apiKey}`;
|
||||
}
|
||||
|
||||
// Fallback to IP if no API key
|
||||
return `ip:${extractClientIp(c)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate rate limit key based on IP only
|
||||
*/
|
||||
export const ipKeyGenerator = (c: Context): string => {
|
||||
return `ip:${extractClientIp(c)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate rate limit key for specific endpoints
|
||||
*/
|
||||
export const endpointKeyGenerator =
|
||||
(endpoint: string) =>
|
||||
(c: Context): string => {
|
||||
const baseKey = userKeyGenerator(c);
|
||||
return `${baseKey}:${endpoint}`;
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
DEFAULT_MAX_REQUESTS,
|
||||
DEFAULT_WINDOW_MS,
|
||||
ERROR_MESSAGES,
|
||||
RATE_LIMIT_KEY_PREFIX
|
||||
} from '$src/constants/rate-limiter';
|
||||
|
||||
import { Redis } from 'ioredis';
|
||||
import { redis } from './redis';
|
||||
|
||||
export class RedisRateLimiter {
|
||||
private redis: Redis;
|
||||
private windowMs: number;
|
||||
private maxRequests: number;
|
||||
|
||||
constructor(
|
||||
redis: Redis,
|
||||
windowMs: number = DEFAULT_WINDOW_MS,
|
||||
maxRequests: number = DEFAULT_MAX_REQUESTS
|
||||
) {
|
||||
this.redis = redis;
|
||||
this.windowMs = windowMs;
|
||||
this.maxRequests = maxRequests;
|
||||
}
|
||||
|
||||
async isAllowed(
|
||||
key: string
|
||||
): Promise<{ allowed: boolean; remaining: number; resetTime: number }> {
|
||||
const now = Date.now();
|
||||
const windowStart = now - this.windowMs;
|
||||
const keyWithPrefix = `${RATE_LIMIT_KEY_PREFIX}${key}`;
|
||||
|
||||
// Use Redis pipeline for atomic operations
|
||||
const pipeline = this.redis.pipeline();
|
||||
|
||||
// Remove expired entries
|
||||
pipeline.zremrangebyscore(keyWithPrefix, '-inf', windowStart);
|
||||
|
||||
// Count current requests in window
|
||||
pipeline.zcard(keyWithPrefix);
|
||||
|
||||
// Add current request
|
||||
pipeline.zadd(keyWithPrefix, now, `${now}-${Math.random()}`);
|
||||
|
||||
// Set expiration for the key
|
||||
pipeline.expire(keyWithPrefix, Math.ceil(this.windowMs / 1000));
|
||||
|
||||
const results = await pipeline.exec();
|
||||
|
||||
if (!results || results.some((result) => result[0] !== null)) {
|
||||
throw new Error(ERROR_MESSAGES.REDIS_PIPELINE_FAILED);
|
||||
}
|
||||
|
||||
const currentCount = results[1][1] as number;
|
||||
const remaining = Math.max(0, this.maxRequests - currentCount - 1);
|
||||
const resetTime = now + this.windowMs;
|
||||
|
||||
return {
|
||||
allowed: currentCount < this.maxRequests,
|
||||
remaining,
|
||||
resetTime
|
||||
};
|
||||
}
|
||||
|
||||
async reset(key: string): Promise<void> {
|
||||
const keyWithPrefix = `${RATE_LIMIT_KEY_PREFIX}${key}`;
|
||||
await this.redis.del(keyWithPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
export const rateLimiter = new RedisRateLimiter(redis);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Redis } from 'ioredis';
|
||||
import { env } from '$src/config/env';
|
||||
|
||||
export const redis = new Redis(env.REDIS_URL);
|
||||
@@ -4,7 +4,7 @@ import { env } from '$src/config/env';
|
||||
|
||||
const { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } = env;
|
||||
|
||||
let supabase: SupabaseClient | null = null;
|
||||
export let supabase: SupabaseClient | null = null;
|
||||
|
||||
interface SupabaseConfig {
|
||||
supabaseConfig: {
|
||||
+22
-11
@@ -1,17 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"baseUrl": "./",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": [
|
||||
"ESNext"
|
||||
],
|
||||
"types": [
|
||||
"@cloudflare/workers-types/2023-07-01"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx"
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
name = "api"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-01-16"
|
||||
|
||||
# compatibility_flags = [ "nodejs_compat" ]
|
||||
|
||||
# [vars]
|
||||
# MY_VAR = "my-variable"
|
||||
|
||||
# [[kv_namespaces]]
|
||||
# binding = "MY_KV_NAMESPACE"
|
||||
# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
# [[r2_buckets]]
|
||||
# binding = "MY_BUCKET"
|
||||
# bucket_name = "my-bucket"
|
||||
|
||||
# [[d1_databases]]
|
||||
# binding = "DB"
|
||||
# database_name = "my-database"
|
||||
# database_id = ""
|
||||
|
||||
# [ai]
|
||||
# binding = "AI"
|
||||
|
||||
# [observability]
|
||||
# enabled = true
|
||||
# head_sampling_rate = 1
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
.env
|
||||
.DS_Store
|
||||
dist
|
||||
@@ -1,10 +0,0 @@
|
||||
# ClassroomIO Backend
|
||||
|
||||
## Supported Features
|
||||
|
||||
1. Certificate download for students.
|
||||
2. Uploading videos to cloudflare (for storage) and Muse.ai (for transcription)
|
||||
|
||||
## Debugging Fly.io
|
||||
|
||||
View logs from fly.io in real time
|
||||
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "0.0.1",
|
||||
"description": "ClassroomIO Backend for long processes",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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/classroomio/classroomio.git"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/classroomio/classroomio/issues"
|
||||
},
|
||||
"homepage": "https://github.com/classroomio/classroomio#readme",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.388.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",
|
||||
"axios": "^1.4.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"hono": "^4.0.5",
|
||||
"hono-rate-limiter": "^0.4.2",
|
||||
"katex": "^0.16.10",
|
||||
"marked": "^7.0.3",
|
||||
"nanoid": "^5.1.5",
|
||||
"nodemailer": "^6.9.5",
|
||||
"zeptomail": "^6.0.0",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/node": "^20.11.19",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@typescript-eslint/eslint-plugin": "7.0.1",
|
||||
"@typescript-eslint/parser": "7.0.1",
|
||||
"@vitest/coverage-v8": "^1.2.2",
|
||||
"eslint": "^9.31.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,66 +0,0 @@
|
||||
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();
|
||||
@@ -1,91 +0,0 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,199 +0,0 @@
|
||||
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import {
|
||||
ZCourseDocumentPresignUrlUpload,
|
||||
ZCourseDownloadPresignedUrl,
|
||||
ZCoursePresignUrlUpload
|
||||
} from '$src/types/course';
|
||||
|
||||
import { BUCKET_NAME } from '$src/constants/upload';
|
||||
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: BUCKET_NAME.VIDEOS,
|
||||
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('/document/upload', authMiddleware, async (c) => {
|
||||
const body = await c.req.json();
|
||||
const result = ZCourseDocumentPresignUrlUpload.parse(body);
|
||||
|
||||
const { fileName, fileType } = result;
|
||||
const fileKey = generateFileKey(fileName);
|
||||
|
||||
try {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: BUCKET_NAME.DOCUMENTS,
|
||||
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: 'Document pre-signed URL generated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating document 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 document 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: BUCKET_NAME.VIDEOS,
|
||||
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
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
presignRouter.post('/download/document', 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: BUCKET_NAME.DOCUMENTS,
|
||||
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: 'Document URLs retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error Retrieving Document:', error);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
type: 'INTERNAL_ERROR',
|
||||
message: 'Error Retrieving Document'
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,6 @@ Below is a demo showing you step by step how to self host the project
|
||||
|
||||
### Next steps
|
||||
|
||||
We want to support self-hosting using a Coolify template or just using a Dockerfile. We currently have Dockerfiles for both our backend and dashboard projects however contributions from the open source community would help us in properly testing and improving them.
|
||||
We want to support self-hosting using a Coolify template or just using a Dockerfile. We currently have Dockerfiles for both our api and dashboard projects however contributions from the open source community would help us in properly testing and improving them.
|
||||
|
||||
If you prefer deployment with Docker, do checkout our [docker-compose.yaml](https://github.com/classroomio/classroomio/blob/main/docker-compose.yaml) file to get started and we can kick off a conversation on Github.
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function POST({ request }) {
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.log('errors from backend', error);
|
||||
console.log('errors from api', error);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -14,7 +14,7 @@ UNSPLASH_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Sever URL - Optional if you want to use video upload and pdf downloads
|
||||
# This is the deployment URL of apps/backend
|
||||
# This is the deployment URL of apps/api
|
||||
PUBLIC_SERVER_URL=
|
||||
|
||||
# For getting metadata for signup users.
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"name": "project-root",
|
||||
"path": "./"
|
||||
},
|
||||
{
|
||||
"name": "supabase-functions",
|
||||
"path": "supabase/functions"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"files.exclude": {
|
||||
"supabase/functions/": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@carbon/charts-svelte": "^1.13.6",
|
||||
"@cio/api": "workspace:*",
|
||||
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0",
|
||||
"@polar-sh/sveltekit": "^0.2.1",
|
||||
"@supabase/supabase-js": "^2.31.0",
|
||||
@@ -51,6 +52,7 @@
|
||||
"js-yaml": "^4.1.0",
|
||||
"jspdf": "^2.5.1",
|
||||
"jspdf-autotable": "^3.8.2",
|
||||
"ky": "^1.10.0",
|
||||
"lodash": "^4.17.21",
|
||||
"openai-edge": "^1.2.2",
|
||||
"papaparse": "^5.4.1",
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
const tailwindcss = require('tailwindcss');
|
||||
const autoprefixer = require('autoprefixer');
|
||||
|
||||
const config = {
|
||||
plugins: [
|
||||
//Some plugins, like tailwindcss/nesting, need to run before Tailwind,
|
||||
tailwindcss(),
|
||||
//But others, like autoprefixer, need to run after,
|
||||
autoprefixer
|
||||
]
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
+9
-11
@@ -4,7 +4,7 @@
|
||||
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
@@ -13,7 +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';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
|
||||
let isLoading = false;
|
||||
let isCourseComplete = false;
|
||||
@@ -24,22 +24,20 @@
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
'/course/download/certificate',
|
||||
{
|
||||
const response = await classroomio.course.download.certificate.$post({
|
||||
json: {
|
||||
theme: `${$course.certificate_theme}`,
|
||||
studentName: `${$profile.fullname}`,
|
||||
courseName: `${$course.title}`,
|
||||
courseDescription: `${$course.description}`,
|
||||
orgLogoUrl: `${$currentOrg.avatar_url}`,
|
||||
orgLogoUrl: $currentOrg.avatar_url || `${$currentOrgDomain}/logo-512.png`,
|
||||
orgName: `${$currentOrg.name}`
|
||||
},
|
||||
{
|
||||
responseType: 'blob'
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const file = new Blob([response.data], { type: 'application/pdf' });
|
||||
const blobResponse = await response.blob();
|
||||
|
||||
const file = new Blob([blobResponse], { type: 'application/pdf' });
|
||||
const fileURL = URL.createObjectURL(file);
|
||||
|
||||
let a = document.createElement('a');
|
||||
|
||||
+60
-98
@@ -1,9 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
uploadCourseDocumentStore,
|
||||
resetDocumentUploadStore,
|
||||
cancelDocumentUpload
|
||||
} from '../../store/lessons';
|
||||
import { lessonDocUpload, resetDocumentUploadStore } from '../../store/lessons';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
@@ -11,18 +7,19 @@
|
||||
import DocumentIcon from 'carbon-icons-svelte/lib/Document.svelte';
|
||||
import PdfIcon from 'carbon-icons-svelte/lib/Pdf.svelte';
|
||||
import CloseIcon from 'carbon-icons-svelte/lib/Close.svelte';
|
||||
import { lesson } from '../../store/lessons';
|
||||
import { apiClient } from '$lib/utils/services/api';
|
||||
import axios from 'axios';
|
||||
import { DocumentUploader } from '$lib/utils/services/courses/presign';
|
||||
import { onDestroy } from 'svelte';
|
||||
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { lesson } from '../../store/lessons';
|
||||
|
||||
export let lessonId = '';
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
let selectedFile: File | null = null;
|
||||
let dragOver = false;
|
||||
let abortController: AbortController | null = null;
|
||||
let errorTimeout: NodeJS.Timeout | null = null;
|
||||
let isDisabled = false;
|
||||
|
||||
const ALLOWED_TYPES = [
|
||||
'application/pdf',
|
||||
@@ -30,9 +27,10 @@
|
||||
'application/msword'
|
||||
];
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['.pdf', '.docx', '.doc'];
|
||||
const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
const documentUploader = new DocumentUploader();
|
||||
|
||||
function getFileType(file: File): 'pdf' | 'docx' | 'doc' {
|
||||
if (file.type === 'application/pdf') return 'pdf';
|
||||
if (file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
|
||||
@@ -79,10 +77,12 @@
|
||||
}
|
||||
|
||||
selectedFile = file;
|
||||
$uploadCourseDocumentStore.error = null;
|
||||
$lessonDocUpload.error = null;
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
if (isDisabled) return;
|
||||
|
||||
event.preventDefault();
|
||||
dragOver = false;
|
||||
|
||||
@@ -93,76 +93,32 @@
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent) {
|
||||
if (isDisabled) return;
|
||||
|
||||
event.preventDefault();
|
||||
dragOver = true;
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
if (isDisabled) return;
|
||||
|
||||
dragOver = false;
|
||||
}
|
||||
|
||||
async function uploadDocument() {
|
||||
if (!selectedFile) return;
|
||||
|
||||
// Create new AbortController for this upload
|
||||
abortController = new AbortController();
|
||||
|
||||
$uploadCourseDocumentStore.isUploading = true;
|
||||
$uploadCourseDocumentStore.uploadProgress = 0;
|
||||
$uploadCourseDocumentStore.error = null;
|
||||
$uploadCourseDocumentStore.isCancelled = false;
|
||||
$lessonDocUpload.isUploading = true;
|
||||
|
||||
try {
|
||||
// Get presigned URL for upload
|
||||
const uploadPresignResponse = await apiClient.post('/course/presign/document/upload', {
|
||||
fileName: selectedFile.name,
|
||||
fileType: selectedFile.type
|
||||
const { url: presignedUrl, fileKey } = await documentUploader.getPresignedUrl(selectedFile);
|
||||
|
||||
await documentUploader.uploadFile({
|
||||
url: presignedUrl,
|
||||
file: selectedFile
|
||||
});
|
||||
|
||||
// Check if upload was cancelled
|
||||
if ($uploadCourseDocumentStore.isCancelled) {
|
||||
throw new Error('Upload cancelled');
|
||||
}
|
||||
const { urls: presignedUrls } = await documentUploader.getDownloadPresignedUrl([fileKey]);
|
||||
|
||||
const presignedUrl = uploadPresignResponse.data.url;
|
||||
|
||||
// Upload file to Cloudflare R2 using presigned URL
|
||||
const uploadResponse = await axios.put(presignedUrl, selectedFile, {
|
||||
headers: {
|
||||
'Content-Type': selectedFile.type
|
||||
},
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
signal: abortController.signal,
|
||||
onUploadProgress: (progressEvent) => {
|
||||
// Check if upload was cancelled during progress
|
||||
if ($uploadCourseDocumentStore.isCancelled) {
|
||||
abortController?.abort();
|
||||
return;
|
||||
}
|
||||
const progress = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 1));
|
||||
$uploadCourseDocumentStore.uploadProgress = progress;
|
||||
}
|
||||
});
|
||||
|
||||
// Check if upload was cancelled
|
||||
if ($uploadCourseDocumentStore.isCancelled) {
|
||||
throw new Error('Upload cancelled');
|
||||
}
|
||||
|
||||
// Get download URL for the uploaded file
|
||||
const downloadPresignedResponse = await apiClient.post('/course/presign/download/document', {
|
||||
keys: [uploadPresignResponse.data.fileKey]
|
||||
});
|
||||
|
||||
if (!downloadPresignedResponse.data.success) {
|
||||
throw new Error(downloadPresignedResponse.data.message);
|
||||
}
|
||||
|
||||
const fileKey = uploadPresignResponse.data.fileKey;
|
||||
const presignedUrls = downloadPresignedResponse.data.urls;
|
||||
|
||||
// Create document object
|
||||
const document = {
|
||||
type: getFileType(selectedFile),
|
||||
name: selectedFile.name,
|
||||
@@ -171,18 +127,18 @@
|
||||
size: selectedFile.size
|
||||
};
|
||||
|
||||
// Add to lesson materials - ensure materials and documents are properly initialized
|
||||
if (!$lesson.materials) {
|
||||
$lesson.materials = { note: '', slide_url: '', videos: [], documents: [] };
|
||||
lesson.update((state) => ({
|
||||
...state,
|
||||
materials: {
|
||||
...(state.materials || {}),
|
||||
documents: [...(state.materials.documents || []), document]
|
||||
}
|
||||
if (!$lesson.materials.documents) {
|
||||
$lesson.materials.documents = [];
|
||||
}
|
||||
$lesson.materials.documents = [...$lesson.materials.documents, document];
|
||||
|
||||
// Update store
|
||||
$uploadCourseDocumentStore.uploadedDocument = document;
|
||||
$uploadCourseDocumentStore.uploadProgress = 100;
|
||||
}));
|
||||
lessonDocUpload.update((state) => ({
|
||||
...state,
|
||||
uploadedDocument: document,
|
||||
uploadProgress: 100
|
||||
}));
|
||||
|
||||
snackbar.success($t('course.navItem.lessons.materials.tabs.document.upload_success'));
|
||||
|
||||
@@ -195,32 +151,29 @@
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
|
||||
if ($uploadCourseDocumentStore.isCancelled) {
|
||||
$uploadCourseDocumentStore.error = $t(
|
||||
if ($lessonDocUpload.isCancelled) {
|
||||
$lessonDocUpload.error = $t(
|
||||
'course.navItem.lessons.materials.tabs.document.upload_cancelled'
|
||||
);
|
||||
snackbar.info($t('course.navItem.lessons.materials.tabs.document.upload_cancelled'));
|
||||
} else {
|
||||
$uploadCourseDocumentStore.error = error instanceof Error ? error.message : 'Upload failed';
|
||||
$lessonDocUpload.error = error instanceof Error ? error.message : 'Upload failed';
|
||||
snackbar.error($t('course.navItem.lessons.materials.tabs.document.upload_error'));
|
||||
}
|
||||
} finally {
|
||||
$uploadCourseDocumentStore.isUploading = false;
|
||||
abortController = null;
|
||||
$lessonDocUpload.isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeSelectedFile() {
|
||||
selectedFile = null;
|
||||
if (fileInput) fileInput.value = '';
|
||||
$uploadCourseDocumentStore.error = null;
|
||||
$lessonDocUpload.error = null;
|
||||
}
|
||||
|
||||
function cancelUpload() {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
}
|
||||
cancelDocumentUpload();
|
||||
documentUploader.cancelUpload();
|
||||
|
||||
selectedFile = null;
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
@@ -235,10 +188,10 @@
|
||||
}
|
||||
|
||||
// Auto-clear error after 5 seconds
|
||||
$: if ($uploadCourseDocumentStore.error) {
|
||||
$: if ($lessonDocUpload.error) {
|
||||
if (errorTimeout) clearTimeout(errorTimeout);
|
||||
errorTimeout = setTimeout(() => {
|
||||
$uploadCourseDocumentStore.error = null;
|
||||
$lessonDocUpload.error = null;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
@@ -246,8 +199,14 @@
|
||||
onDestroy(() => {
|
||||
if (errorTimeout) clearTimeout(errorTimeout);
|
||||
});
|
||||
|
||||
$: isDisabled = $lessonDocUpload.isUploading || $isFreePlan;
|
||||
</script>
|
||||
|
||||
<UpgradeBanner className="mb-3" onClick={() => ($lessonDocUpload.isModalOpen = false)}>
|
||||
{$t('course.navItem.lessons.materials.tabs.document.upgrade')}
|
||||
</UpgradeBanner>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="mb-2 text-lg font-semibold">
|
||||
@@ -261,7 +220,8 @@
|
||||
<!-- File Upload Area -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center transition-colors {dragOver
|
||||
class="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center transition-colors {isDisabled &&
|
||||
'opacity-50 hover:cursor-not-allowed'} {dragOver
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'hover:border-gray-400'}"
|
||||
on:drop={handleDrop}
|
||||
@@ -289,6 +249,7 @@
|
||||
</p>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
disabled={isDisabled}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc"
|
||||
on:change={handleFileSelect}
|
||||
@@ -298,6 +259,7 @@
|
||||
<PrimaryButton
|
||||
label={$t('course.navItem.lessons.materials.tabs.document.choose_file')}
|
||||
variant={VARIANTS.OUTLINED}
|
||||
{isDisabled}
|
||||
onClick={() => fileInput?.click()}
|
||||
/>
|
||||
</div>
|
||||
@@ -305,23 +267,23 @@
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if $uploadCourseDocumentStore.error}
|
||||
{#if $lessonDocUpload.error}
|
||||
<div class="mt-4 rounded-md border border-red-200 bg-red-50 p-3">
|
||||
<p class="text-sm text-red-600">{$uploadCourseDocumentStore.error}</p>
|
||||
<p class="text-sm text-red-600">{$lessonDocUpload.error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Upload Progress -->
|
||||
{#if $uploadCourseDocumentStore.isUploading}
|
||||
{#if $lessonDocUpload.isUploading}
|
||||
<div class="mt-4">
|
||||
<div class="mb-1 flex justify-between text-sm text-gray-600">
|
||||
<span>{$t('course.navItem.lessons.materials.tabs.document.uploading')}</span>
|
||||
<span>{$uploadCourseDocumentStore.uploadProgress}%</span>
|
||||
<span>{$lessonDocUpload.uploadProgress}%</span>
|
||||
</div>
|
||||
<div class="h-2 w-full rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-2 rounded-full bg-blue-600 transition-all duration-300"
|
||||
style="width: {$uploadCourseDocumentStore.uploadProgress}%"
|
||||
style="width: {$lessonDocUpload.uploadProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-center">
|
||||
@@ -335,7 +297,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- Upload Button -->
|
||||
{#if selectedFile && !$uploadCourseDocumentStore.isUploading}
|
||||
{#if selectedFile && !$lessonDocUpload.isUploading}
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<PrimaryButton
|
||||
label={$t('course.navItem.lessons.materials.tabs.document.cancel')}
|
||||
@@ -345,13 +307,13 @@
|
||||
<PrimaryButton
|
||||
label={$t('course.navItem.lessons.materials.tabs.document.upload_document')}
|
||||
onClick={uploadDocument}
|
||||
isLoading={$uploadCourseDocumentStore.isUploading}
|
||||
isLoading={$lessonDocUpload.isUploading}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Success Message -->
|
||||
{#if $uploadCourseDocumentStore.uploadedDocument}
|
||||
{#if $lessonDocUpload.uploadedDocument}
|
||||
<div class="mt-4 rounded-md border border-green-200 bg-green-50 p-3">
|
||||
<p class="text-sm text-green-600">
|
||||
{$t('course.navItem.lessons.materials.tabs.document.upload_success')}
|
||||
+7
-4
@@ -11,22 +11,25 @@ export interface DocumentUploadState {
|
||||
size: number;
|
||||
} | null;
|
||||
error: string | null;
|
||||
isCancelled: boolean;
|
||||
}
|
||||
|
||||
export const uploadCourseDocumentStore = writable<DocumentUploadState>({
|
||||
export const lessonDocUpload = writable<DocumentUploadState>({
|
||||
isUploading: false,
|
||||
isModalOpen: false,
|
||||
uploadProgress: 0,
|
||||
uploadedDocument: null,
|
||||
error: null
|
||||
error: null,
|
||||
isCancelled: false
|
||||
});
|
||||
|
||||
export function resetDocumentUploadStore() {
|
||||
uploadCourseDocumentStore.set({
|
||||
lessonDocUpload.set({
|
||||
isUploading: false,
|
||||
isModalOpen: false,
|
||||
uploadProgress: 0,
|
||||
uploadedDocument: null,
|
||||
error: null
|
||||
error: null,
|
||||
isCancelled: false
|
||||
});
|
||||
}
|
||||
|
||||
+46
-86
@@ -1,26 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { env } from '$env/dynamic/public';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
lesson,
|
||||
deleteLessonVideo,
|
||||
uploadCourseVideoStore,
|
||||
lessonVideoUpload,
|
||||
cancelVideoUpload
|
||||
} from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { type ComponentProps } from 'svelte';
|
||||
import { ProgressBar } from 'carbon-components-svelte';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
|
||||
import { apiClient } from '$lib/utils/services/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import { VideoUploader } from '$lib/utils/services/courses/presign';
|
||||
|
||||
export let lessonId = '';
|
||||
|
||||
let value = 0;
|
||||
let max = 100;
|
||||
let status: ComponentProps<ProgressBar>['status'] = 'active';
|
||||
let fileSize;
|
||||
let isDisabled = false;
|
||||
|
||||
@@ -29,66 +24,32 @@
|
||||
let fileInput;
|
||||
let submit;
|
||||
let fileName = '';
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
const videoUploader = new VideoUploader();
|
||||
|
||||
async function onUpload() {
|
||||
if (!fileInput) return;
|
||||
|
||||
// Create new AbortController for this upload
|
||||
abortController = new AbortController();
|
||||
|
||||
$uploadCourseVideoStore.isUploading = true;
|
||||
$uploadCourseVideoStore.isCancelled = false;
|
||||
videoUploader.initUpload();
|
||||
|
||||
const videoFile = fileInput.files[0];
|
||||
fileSize = videoFile?.size / (1024 * 1024);
|
||||
fileName = videoFile?.name;
|
||||
|
||||
try {
|
||||
const uploadPresignResponse = await apiClient.post('/course/presign/upload', {
|
||||
fileName: videoFile.name,
|
||||
fileType: videoFile.type
|
||||
const { url: presignedUrl, fileKey } = await videoUploader.getPresignedUrl(videoFile);
|
||||
|
||||
await videoUploader.uploadFile({
|
||||
url: presignedUrl,
|
||||
file: videoFile
|
||||
});
|
||||
|
||||
const presignedUrl = uploadPresignResponse.data.url;
|
||||
|
||||
const uploadResponse = await axios.put(presignedUrl, videoFile, {
|
||||
headers: {
|
||||
'Content-Type': videoFile.type
|
||||
},
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
signal: abortController.signal,
|
||||
onUploadProgress: (progressEvent) => {
|
||||
// Check if upload was cancelled during progress
|
||||
if ($uploadCourseVideoStore.isCancelled) {
|
||||
abortController?.abort();
|
||||
return;
|
||||
}
|
||||
value = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 1) / 2);
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
const { urls: presignedUrls } = await videoUploader.getDownloadPresignedUrl([fileKey]);
|
||||
|
||||
formRes = {
|
||||
url: presignedUrls[fileKey],
|
||||
fileKey: uploadPresignResponse.data.fileKey,
|
||||
status: uploadResponse.status
|
||||
fileKey: fileKey,
|
||||
status: 200
|
||||
};
|
||||
|
||||
$lesson.materials.videos = [
|
||||
@@ -100,12 +61,11 @@
|
||||
}
|
||||
];
|
||||
|
||||
$uploadCourseVideoStore.isUploading = false;
|
||||
isLoaded = false;
|
||||
} catch (err: any) {
|
||||
console.error('Error uploading video', err, '\n\n', err.response);
|
||||
|
||||
if ($uploadCourseVideoStore.isCancelled) {
|
||||
if ($lessonVideoUpload.isCancelled) {
|
||||
formRes = {
|
||||
type: 'CANCELLED',
|
||||
status: 0,
|
||||
@@ -114,53 +74,54 @@
|
||||
} else if (err.response) {
|
||||
formRes = err.response.data;
|
||||
} else {
|
||||
let message = 'An error occurred while uploading the video';
|
||||
if (err instanceof Error) {
|
||||
message = err.message;
|
||||
}
|
||||
formRes = {
|
||||
type: 'INTERNAL_ERROR',
|
||||
status: 500,
|
||||
message: 'An error occurred while uploading the video'
|
||||
message
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
abortController = null;
|
||||
videoUploader.abortController = null;
|
||||
}
|
||||
|
||||
isLoaded = true;
|
||||
$lessonVideoUpload.isUploading = false;
|
||||
}
|
||||
|
||||
function tryAgain() {
|
||||
formRes = null;
|
||||
isLoaded = false;
|
||||
$uploadCourseVideoStore.isUploading = false;
|
||||
value = 0;
|
||||
$lessonVideoUpload.isUploading = false;
|
||||
$lessonVideoUpload.uploadProgress = 0;
|
||||
}
|
||||
|
||||
function cancelUpload() {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
}
|
||||
videoUploader.abortController?.abort();
|
||||
|
||||
cancelVideoUpload();
|
||||
|
||||
formRes = null;
|
||||
isLoaded = false;
|
||||
value = 0;
|
||||
$lessonVideoUpload.uploadProgress = 0;
|
||||
}
|
||||
|
||||
$: helperText = value + '% of ' + Math.round(fileSize) + 'MB';
|
||||
$: if (value === max) {
|
||||
helperText = 'Done';
|
||||
status = 'finished';
|
||||
}
|
||||
$: helperText = $lessonVideoUpload.uploadProgress + '% of ' + Math.round(fileSize) + 'MB';
|
||||
|
||||
$: isDisabled = $uploadCourseVideoStore.isUploading || !env.PUBLIC_SERVER_URL || $isFreePlan;
|
||||
$: isDisabled = $lessonVideoUpload.isUploading || !env.PUBLIC_SERVER_URL || $isFreePlan;
|
||||
</script>
|
||||
|
||||
<UpgradeBanner className="mb-3" onClick={() => ($uploadCourseVideoStore.isModalOpen = false)}>
|
||||
<UpgradeBanner className="mb-3" onClick={() => ($lessonVideoUpload.isModalOpen = false)}>
|
||||
{$t('course.navItem.lessons.materials.tabs.video.add_video.upgrade')}
|
||||
</UpgradeBanner>
|
||||
|
||||
{#if !isLoaded}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (fileInput && !$uploadCourseVideoStore.isUploading ? fileInput.click() : null)}
|
||||
on:click={() => (fileInput && !$lessonVideoUpload.isUploading ? fileInput.click() : null)}
|
||||
class="h-full w-full {isDisabled && 'opacity-50 hover:cursor-not-allowed'}"
|
||||
disabled={isDisabled}
|
||||
>
|
||||
@@ -168,12 +129,17 @@
|
||||
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 $uploadCourseVideoStore.isUploading}
|
||||
{#if $lessonVideoUpload.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>
|
||||
<ProgressBar class="w-full" {value} {max} {status} />
|
||||
<ProgressBar
|
||||
class="w-full"
|
||||
value={$lessonVideoUpload.uploadProgress}
|
||||
max={100}
|
||||
status={$lessonVideoUpload.uploadProgress === 100 ? 'finished' : 'active'}
|
||||
/>
|
||||
<p class="text-sm">{helperText}</p>
|
||||
<div class="mt-3">
|
||||
<PrimaryButton
|
||||
@@ -242,25 +208,19 @@
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full w-full flex-col items-start justify-between">
|
||||
<div class="h-auto w-full rounded-md border px-8 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center gap-2">
|
||||
<div class="overflow-hidden rounded-sm">
|
||||
<video class="w-[200px]">
|
||||
<div class=" w-full rounded-md border px-8 py-3">
|
||||
<div class="flex flex-col items-center gap-8">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<video class="border-primary-300 w-[200px] rounded-md border">
|
||||
<source src={formRes.url} type="video/mp4" />
|
||||
<track kind="captions" />
|
||||
<p>{$t('generic.loading')}</p>
|
||||
</video>
|
||||
</div>
|
||||
<p>{fileInput?.files?.[0]?.name || fileName}</p>
|
||||
</span>
|
||||
<button on:click={deleteLessonVideo}>
|
||||
<img src="/delete-video.svg" alt="deletevideo" class="dark:invert" />
|
||||
</button>
|
||||
</div>
|
||||
<PrimaryButton
|
||||
label={$t('course.navItem.lessons.materials.button_done')}
|
||||
onClick={() => ($uploadCourseVideoStore.isModalOpen = false)}
|
||||
onClick={() => ($lessonVideoUpload.isModalOpen = false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+15
-54
@@ -3,10 +3,7 @@
|
||||
lesson,
|
||||
deleteLessonDocument
|
||||
} from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import {
|
||||
uploadCourseDocumentStore,
|
||||
presignedDocUrls
|
||||
} from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { lessonDocUpload } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import MODES from '$lib/utils/constants/mode';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import CloseIcon from 'carbon-icons-svelte/lib/Close.svelte';
|
||||
@@ -17,7 +14,6 @@
|
||||
import { onMount } from 'svelte';
|
||||
import DocumentList from '../Document/DocumentList.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { apiClient } from '$lib/utils/services/api';
|
||||
import type { LessonDocument } from '$lib/utils/types';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
|
||||
@@ -56,7 +52,7 @@
|
||||
});
|
||||
|
||||
function openDocumentUploadModal() {
|
||||
$uploadCourseDocumentStore.isModalOpen = true;
|
||||
$lessonDocUpload.isModalOpen = true;
|
||||
}
|
||||
|
||||
function deleteDocument(index: number) {
|
||||
@@ -71,36 +67,6 @@
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
async function getPresignedDocs(docs: LessonDocument[]) {
|
||||
if (!docs.length) return {};
|
||||
if (docs.every((d) => $presignedDocUrls[d.key || ''])) return;
|
||||
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.post('/course/presign/download/document', {
|
||||
keys: docs.map((d) => d.key)
|
||||
});
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message);
|
||||
}
|
||||
|
||||
for (const key in data.urls) {
|
||||
presignedDocUrls.update((urls) => ({
|
||||
...urls,
|
||||
[key]: data.urls[key]
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
isLoading = false;
|
||||
console.error('Error getting presigned docs:', error);
|
||||
snackbar.error('Error getting presigned docs');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadDocument(doc: LessonDocument) {
|
||||
downloadingDocuments.add(doc.name);
|
||||
downloadingDocuments = downloadingDocuments;
|
||||
@@ -111,7 +77,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch($presignedDocUrls[doc.key]);
|
||||
const response = await fetch(doc.link);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch document');
|
||||
}
|
||||
@@ -142,16 +108,6 @@
|
||||
renderTimeout = null;
|
||||
}, 150); // 150ms debounce
|
||||
}
|
||||
|
||||
async function viewPDF(document: LessonDocument) {
|
||||
// Wait for PDF.js to be fully loaded
|
||||
if (!pdfjsLib) {
|
||||
// Show loading state while waiting for PDF.js
|
||||
viewingPDF = document;
|
||||
pdfViewerOpen = true;
|
||||
isLoading = true;
|
||||
error = 'Loading PDF viewer...';
|
||||
|
||||
// Wait for PDF.js to load
|
||||
const checkPDFJS = () => {
|
||||
return new Promise((resolve) => {
|
||||
@@ -166,6 +122,15 @@
|
||||
});
|
||||
};
|
||||
|
||||
async function viewPDF(document: LessonDocument) {
|
||||
// Wait for PDF.js to be fully loaded
|
||||
if (!pdfjsLib) {
|
||||
// Show loading state while waiting for PDF.js
|
||||
viewingPDF = document;
|
||||
pdfViewerOpen = true;
|
||||
isLoading = true;
|
||||
error = 'Loading PDF viewer...';
|
||||
|
||||
await checkPDFJS();
|
||||
}
|
||||
|
||||
@@ -180,7 +145,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch($presignedDocUrls[document.key]);
|
||||
const response = await fetch(document.link);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch PDF');
|
||||
@@ -335,12 +300,9 @@
|
||||
}
|
||||
|
||||
$: displayDocuments = $lesson?.materials?.documents || [];
|
||||
|
||||
$: getPresignedDocs(displayDocuments);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto w-full max-w-lg">
|
||||
<DocumentList
|
||||
<DocumentList
|
||||
{isLoading}
|
||||
{mode}
|
||||
{displayDocuments}
|
||||
@@ -350,8 +312,7 @@
|
||||
{deleteDocument}
|
||||
{downloadDocument}
|
||||
on:viewPDF={handleViewPDF}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
|
||||
<!-- PDF Viewer Modal -->
|
||||
{#if pdfViewerOpen}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import VideoUploader from '$lib/components/Course/components/Lesson/Materials/Video/Index.svelte';
|
||||
import DocumentUploader from '$lib/components/Course/components/Lesson/Materials/Document/Index.svelte';
|
||||
import AddVideoToLesson from '$lib/components/Course/components/Lesson/Materials/Video/AddVideoToLesson.svelte';
|
||||
import AddDocumentToLesson from '$lib/components/Course/components/Lesson/Materials/Document/AddDocumentToLesson.svelte';
|
||||
import {
|
||||
deleteLessonVideo,
|
||||
handleUpdateLessonMaterials,
|
||||
@@ -9,8 +9,8 @@
|
||||
lesson,
|
||||
lessonByTranslation,
|
||||
lessons,
|
||||
uploadCourseVideoStore,
|
||||
uploadCourseDocumentStore
|
||||
lessonVideoUpload,
|
||||
lessonDocUpload
|
||||
} from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
@@ -47,6 +47,7 @@
|
||||
import * as CONSTANTS from './constants';
|
||||
import { orderedTabs } from './constants';
|
||||
import Loader from './Loader.svelte';
|
||||
|
||||
export let mode = MODES.view;
|
||||
export let prevMode = '';
|
||||
export let lessonId = '';
|
||||
@@ -57,7 +58,6 @@
|
||||
let localeExists: Record<string, boolean> = {};
|
||||
let lessonTitle = '';
|
||||
let prevContent = '';
|
||||
let initAutoSave = false;
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
let tabs = CONSTANTS.tabs;
|
||||
let currentTab = tabs[0].value;
|
||||
@@ -182,7 +182,7 @@
|
||||
}
|
||||
|
||||
const openAddVideoModal = () => {
|
||||
$uploadCourseVideoStore.isModalOpen = true;
|
||||
$lessonVideoUpload.isModalOpen = true;
|
||||
};
|
||||
|
||||
const { input, handleSubmit, completion, isLoading } = useCompletion({
|
||||
@@ -244,11 +244,6 @@
|
||||
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
if (!initAutoSave) {
|
||||
initAutoSave = true;
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
timeoutId = setTimeout(async () => {
|
||||
const { error } = await saveLesson(updatedMaterials);
|
||||
@@ -262,7 +257,6 @@
|
||||
}
|
||||
|
||||
async function onLessonIdChange(_lid: string) {
|
||||
initAutoSave = false;
|
||||
isSaving = false;
|
||||
|
||||
tabs = orderedTabs(tabs, $course.metadata?.lessonTabsOrder);
|
||||
@@ -271,18 +265,18 @@
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
if ($uploadCourseVideoStore.isUploading) return;
|
||||
if ($lessonVideoUpload.isUploading) return;
|
||||
|
||||
$uploadCourseVideoStore.isModalOpen = false;
|
||||
$lessonVideoUpload.isModalOpen = false;
|
||||
autoSave($lesson.materials, $lessonByTranslation[lessonId], $isLoading, lessonId);
|
||||
};
|
||||
|
||||
const onDocumentClose = () => {
|
||||
if ($uploadCourseDocumentStore.isUploading) return;
|
||||
if ($lessonDocUpload.isUploading) return;
|
||||
|
||||
$uploadCourseDocumentStore.isModalOpen = false;
|
||||
$lessonDocUpload.isModalOpen = false;
|
||||
// Clear any error messages when modal closes
|
||||
$uploadCourseDocumentStore.error = null;
|
||||
$lessonDocUpload.error = null;
|
||||
autoSave($lesson.materials, $lessonByTranslation[lessonId], $isLoading, lessonId);
|
||||
};
|
||||
|
||||
@@ -327,20 +321,20 @@
|
||||
|
||||
<Modal
|
||||
{onClose}
|
||||
bind:open={$uploadCourseVideoStore.isModalOpen}
|
||||
bind:open={$lessonVideoUpload.isModalOpen}
|
||||
width="w-4/5 w-[90%] h-[80%] md:h-[566px]"
|
||||
modalHeading={$t('course.navItem.lessons.materials.tabs.video.add_video.title')}
|
||||
>
|
||||
<VideoUploader {lessonId} />
|
||||
<AddVideoToLesson {lessonId} />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
onClose={onDocumentClose}
|
||||
bind:open={$uploadCourseDocumentStore.isModalOpen}
|
||||
bind:open={$lessonDocUpload.isModalOpen}
|
||||
width="w-4/5 w-[90%] h-[80%] md:h-[566px]"
|
||||
modalHeading={$t('course.navItem.lessons.materials.tabs.document.upload_title')}
|
||||
>
|
||||
<DocumentUploader {lessonId} />
|
||||
<AddDocumentToLesson {lessonId} />
|
||||
</Modal>
|
||||
|
||||
<HtmlRender className="m-auto text-center">
|
||||
@@ -493,7 +487,13 @@
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<video bind:this={player} class="plyr-video-trigger" playsinline controls>
|
||||
<video
|
||||
bind:this={player}
|
||||
class="plyr-video-trigger"
|
||||
style="max-height: 569px;"
|
||||
playsinline
|
||||
controls
|
||||
>
|
||||
<source src={video.link} type="video/mp4" />
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
|
||||
@@ -15,22 +15,22 @@ 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,
|
||||
isCancelled: false
|
||||
});
|
||||
|
||||
export const uploadCourseDocumentStore = writable({
|
||||
isUploading: false,
|
||||
isModalOpen: false,
|
||||
uploadProgress: 0,
|
||||
uploadedDocument: null as any,
|
||||
export const lessonVideoUpload = writable({
|
||||
error: null as string | null,
|
||||
isCancelled: false
|
||||
isCancelled: false,
|
||||
isModalOpen: false,
|
||||
isUploading: false,
|
||||
uploadProgress: 0
|
||||
});
|
||||
|
||||
export const presignedDocUrls = writable<Record<string, string>>({});
|
||||
export const lessonDocUpload = writable({
|
||||
error: null as string | null,
|
||||
isCancelled: false,
|
||||
isModalOpen: false,
|
||||
isUploading: false,
|
||||
uploadedDocument: null as any,
|
||||
uploadProgress: 0
|
||||
});
|
||||
|
||||
export const lessons: Writable<Lesson[]> = writable([]);
|
||||
|
||||
@@ -217,7 +217,7 @@ export const deleteLessonDocument = (index: any) => {
|
||||
};
|
||||
|
||||
export function resetDocumentUploadStore() {
|
||||
uploadCourseDocumentStore.set({
|
||||
lessonDocUpload.set({
|
||||
isUploading: false,
|
||||
isModalOpen: false,
|
||||
uploadProgress: 0,
|
||||
@@ -228,7 +228,7 @@ export function resetDocumentUploadStore() {
|
||||
}
|
||||
|
||||
export function cancelDocumentUpload() {
|
||||
uploadCourseDocumentStore.update((store) => ({
|
||||
lessonDocUpload.update((store) => ({
|
||||
...store,
|
||||
isCancelled: true,
|
||||
isUploading: false,
|
||||
@@ -237,7 +237,7 @@ export function cancelDocumentUpload() {
|
||||
}
|
||||
|
||||
export function cancelVideoUpload() {
|
||||
uploadCourseVideoStore.update((store) => ({
|
||||
lessonVideoUpload.update((store) => ({
|
||||
...store,
|
||||
isCancelled: true,
|
||||
isUploading: false
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
to: $profile.email,
|
||||
name: $profile.fullname
|
||||
});
|
||||
|
||||
goto($currentOrgPath + '/courses?create=true');
|
||||
};
|
||||
</script>
|
||||
@@ -29,7 +30,7 @@
|
||||
maxWidth="w-[800px]"
|
||||
modalHeading="Welcome"
|
||||
>
|
||||
<p class="text-black dark:text-white text-sm md:text-base lg:text-lg">
|
||||
<p class="text-sm text-black md:text-base lg:text-lg dark:text-white">
|
||||
{$t('welcome_modal.we_at')}
|
||||
<a href="https://app.classroomio.com/" class="text-primary-700 no-underline hover:no-underline"
|
||||
>ClassroomIO</a
|
||||
@@ -38,5 +39,5 @@
|
||||
<span class="text-primary-700">{$t('welcome_modal.thank_you')};</span>
|
||||
{$t('welcome_modal.deeply_appreciate')}
|
||||
</p>
|
||||
<img src="/images/welcome-img.svg" alt="A welcome banner" class="w-full my-6" />
|
||||
<img src="/images/welcome-img.svg" alt="A welcome banner" class="my-6 w-full" />
|
||||
</Modal>
|
||||
|
||||
@@ -80,7 +80,7 @@ export const addMathPlugin = (tinymce: any) => {
|
||||
onSubmit: async function (w) {
|
||||
let content = output.exportAs('html');
|
||||
|
||||
// The generated content always points to https://latex.codecogs.com/svg.image, this means we have to always rely on their server to serve formulas. We have a dedicated endpoint for generating formulas on our backend service: GET `/katex`. We will need to manually update the URL in the generate HTML to point to our server:
|
||||
// The generated content always points to https://latex.codecogs.com/svg.image, this means we have to always rely on their server to serve formulas. We have a dedicated endpoint for generating formulas on our api service: GET `/katex`. We will need to manually update the URL in the generate HTML to point to our server:
|
||||
// https://latex.codecogs.com/svg.image
|
||||
|
||||
// 1. Get only the URL in the img tag generated by codecogs library
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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;
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
# API Client
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { apiClient, safeRequest, classroomio } from '$lib/utils/services/api';
|
||||
|
||||
// GET request
|
||||
const result = await safeRequest(async () => {
|
||||
return apiClient.request('/api/users', { method: 'GET' });
|
||||
});
|
||||
|
||||
// POST request
|
||||
const result = await safeRequest(async () => {
|
||||
return apiClient.request('/api/users', {
|
||||
method: 'POST',
|
||||
body: { name: 'John', email: 'john@example.com' }
|
||||
});
|
||||
});
|
||||
|
||||
// Handle response
|
||||
if (result.success) {
|
||||
console.log('Data:', result.data);
|
||||
} else {
|
||||
console.error('Error:', result.error.message);
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
if (result.success) {
|
||||
// Success
|
||||
console.log(result.data);
|
||||
} else {
|
||||
// Check error type
|
||||
if (result.error.error_code === 'auth') {
|
||||
window.location.href = '/login';
|
||||
} else if (result.error.error_code === 'client') {
|
||||
showError('Please check your input');
|
||||
} else if (result.error.error_code === 'server') {
|
||||
showError('Server error, please try again');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Fetch
|
||||
|
||||
```typescript
|
||||
import { createApiClient } from '$lib/utils/services/api';
|
||||
|
||||
const customClient = createApiClient({
|
||||
customFetch: (input, init) => {
|
||||
// Your custom fetch logic
|
||||
return fetch(input, init);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await safeRequest(async () => {
|
||||
return customClient.request('/api/data', { method: 'GET' });
|
||||
});
|
||||
```
|
||||
|
||||
## RPC Usage
|
||||
|
||||
```typescript
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
|
||||
// Use with Hono RPC
|
||||
const data = await classroomio.users.$get();
|
||||
const user = await classroomio.users.$post({ json: { name: 'John' } });
|
||||
```
|
||||
|
||||
## Error Codes
|
||||
|
||||
- `'auth'` - 401 Unauthorized
|
||||
- `'client'` - 400-499 Client errors
|
||||
- `'server'` - 500-599 Server errors
|
||||
- `'timeout'` - 408 Request timeout
|
||||
- `'network'` - 0 Network issues
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ApiClientConfig } from './types';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
// Default configuration
|
||||
export const DEFAULT_CONFIG: Required<ApiClientConfig> = {
|
||||
baseURL: env.PUBLIC_SERVER_URL || '',
|
||||
timeout: 30000, // 30 seconds
|
||||
retries: 1,
|
||||
retryDelay: 1000, // 1 second
|
||||
customFetch: fetch,
|
||||
onAuthError: async () => {
|
||||
// Default: redirect to login or refresh token
|
||||
console.warn('Authentication error occurred. Consider redirecting to login.');
|
||||
},
|
||||
onNetworkError: async (error: Error) => {
|
||||
console.error('Network error:', error);
|
||||
},
|
||||
onResponse: async (response: Response) => {
|
||||
// Default: log response for debugging
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`API Response: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
import { type ApiClientConfig, ApiError, type RequestConfig } from './types';
|
||||
|
||||
import { DEFAULT_CONFIG } from './constants';
|
||||
import { delay } from './utils';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { getAccessToken } from '$lib/utils/functions/supabase';
|
||||
import { hcWithType } from '@cio/api/rpc-types';
|
||||
|
||||
class ApiClient {
|
||||
private config: Required<ApiClientConfig>;
|
||||
|
||||
constructor(config: ApiClientConfig = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
private async makeRequest(
|
||||
input: RequestInfo | URL,
|
||||
requestConfig: RequestConfig = {}
|
||||
): Promise<Response> {
|
||||
console.log('makeRequest', input, requestConfig);
|
||||
const {
|
||||
timeout = this.config.timeout,
|
||||
retries = this.config.retries,
|
||||
skipAuth = false,
|
||||
...fetchConfig
|
||||
} = requestConfig;
|
||||
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
const fullUrl = url.startsWith('http') ? url : `${this.config.baseURL}${url}`;
|
||||
|
||||
// Prepare headers
|
||||
const headers = new Headers(fetchConfig.headers);
|
||||
|
||||
// Add default headers
|
||||
if (!headers.has('Accept')) {
|
||||
headers.set('Accept', 'application/json');
|
||||
}
|
||||
|
||||
// Handle body serialization and content-type
|
||||
let requestBody = fetchConfig.body;
|
||||
if (
|
||||
fetchConfig.body &&
|
||||
typeof fetchConfig.body === 'object' &&
|
||||
!(fetchConfig.body instanceof FormData)
|
||||
) {
|
||||
// Auto-stringify objects (except FormData)
|
||||
requestBody = JSON.stringify(fetchConfig.body);
|
||||
if (!headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
} else if (fetchConfig.body && !headers.has('Content-Type')) {
|
||||
// Set content-type for other body types if not already set
|
||||
if (typeof fetchConfig.body === 'string') {
|
||||
headers.set('Content-Type', 'text/plain');
|
||||
}
|
||||
}
|
||||
|
||||
// Add authentication if not skipped
|
||||
if (!skipAuth) {
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to get access token:', error);
|
||||
// Continue without auth if token retrieval fails
|
||||
}
|
||||
}
|
||||
|
||||
// Create abort controller for timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
const requestInit: RequestInit = {
|
||||
...fetchConfig,
|
||||
headers,
|
||||
body: requestBody,
|
||||
signal: controller.signal
|
||||
};
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
// Retry logic with exponential backoff
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const response = await this.config.customFetch(fullUrl, requestInit);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
await this.config.onResponse(response);
|
||||
|
||||
if (response.ok) {
|
||||
return response;
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
await this.config.onAuthError();
|
||||
throw new ApiError(
|
||||
'Authentication failed',
|
||||
response.status,
|
||||
response.statusText,
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new ApiError(
|
||||
`Client error: ${errorText}`,
|
||||
response.status,
|
||||
response.statusText,
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
const error = new ApiError(
|
||||
`Server error: ${response.statusText}`,
|
||||
response.status,
|
||||
response.statusText,
|
||||
response
|
||||
);
|
||||
|
||||
if (attempt === retries) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
lastError = error;
|
||||
await delay(this.config.retryDelay * Math.pow(2, attempt));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Other status codes
|
||||
throw new ApiError(
|
||||
`Unexpected status: ${response.statusText}`,
|
||||
response.status,
|
||||
response.statusText,
|
||||
response
|
||||
);
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Handle abort (timeout)
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new ApiError('Request timeout', 408, 'Request Timeout');
|
||||
}
|
||||
|
||||
// Handle network errors - retryable
|
||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||
const networkError = new ApiError('Network error', 0, 'Network Error');
|
||||
|
||||
if (attempt === retries) {
|
||||
await this.config.onNetworkError(networkError);
|
||||
throw networkError;
|
||||
}
|
||||
|
||||
lastError = networkError;
|
||||
await delay(this.config.retryDelay * Math.pow(2, attempt));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Re-throw non-retryable errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all retries failed
|
||||
throw lastError || new ApiError('Request failed after all retries');
|
||||
}
|
||||
|
||||
// Generic request method - handles all HTTP methods
|
||||
async request(input: RequestInfo | URL, config: RequestConfig = {}): Promise<Response> {
|
||||
return this.makeRequest(input, config);
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
updateConfig(newConfig: Partial<ApiClientConfig>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
}
|
||||
}
|
||||
|
||||
// Create default instance
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
// RPC client using the new fetch wrapper
|
||||
export const classroomio = hcWithType(env.PUBLIC_SERVER_URL, {
|
||||
fetch: async (input: RequestInfo | URL, requestInit?: RequestInit) => {
|
||||
return apiClient.request(input, requestInit);
|
||||
}
|
||||
});
|
||||
|
||||
// Utility functions for common use cases
|
||||
export const createApiClient = (config: ApiClientConfig) => new ApiClient(config);
|
||||
|
||||
// Re-export utility functions for convenience
|
||||
export {
|
||||
handleJsonResponse,
|
||||
handleTextResponse,
|
||||
isApiError,
|
||||
isNetworkError,
|
||||
isAuthError,
|
||||
isClientError,
|
||||
isServerError,
|
||||
isTimeoutError,
|
||||
safeRequest,
|
||||
safeRequestText,
|
||||
safeRequestRaw,
|
||||
type Result
|
||||
} from './utils';
|
||||
|
||||
export type { InferResponseType, InferRequestType } from '@cio/api/rpc-types';
|
||||
@@ -0,0 +1,46 @@
|
||||
// Types for better type safety
|
||||
export interface ApiClientConfig {
|
||||
baseURL?: string;
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
retryDelay?: number;
|
||||
customFetch?: typeof fetch;
|
||||
onAuthError?: () => Promise<void> | void;
|
||||
onNetworkError?: (error: Error) => Promise<void> | void;
|
||||
onResponse?: (response: Response) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface RequestConfig extends RequestInit {
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
skipAuth?: boolean;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
public readonly error_code: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
public status?: number,
|
||||
public statusText?: string,
|
||||
public response?: Response
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
|
||||
// Determine error code based on status
|
||||
if (status === 401) {
|
||||
this.error_code = 'auth';
|
||||
} else if (status && status >= 400 && status < 500) {
|
||||
this.error_code = 'client';
|
||||
} else if (status && status >= 500) {
|
||||
this.error_code = 'server';
|
||||
} else if (status === 408) {
|
||||
this.error_code = 'timeout';
|
||||
} else if (status === 0 || !status) {
|
||||
this.error_code = 'network';
|
||||
} else {
|
||||
this.error_code = 'unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { ApiError } from './types';
|
||||
|
||||
// Utility function for exponential backoff
|
||||
export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Helper function to handle JSON responses
|
||||
export const handleJsonResponse = async <T = any>(response: Response): Promise<T> => {
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
`HTTP ${response.status}: ${response.statusText}`,
|
||||
response.status,
|
||||
response.statusText,
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new ApiError(
|
||||
'Failed to parse JSON response',
|
||||
response.status,
|
||||
response.statusText,
|
||||
response
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to handle text responses
|
||||
export const handleTextResponse = async (response: Response): Promise<string> => {
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
`HTTP ${response.status}: ${response.statusText}`,
|
||||
response.status,
|
||||
response.statusText,
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
};
|
||||
|
||||
// Error handling utilities
|
||||
export const isApiError = (error: unknown): error is ApiError => {
|
||||
return error instanceof ApiError;
|
||||
};
|
||||
|
||||
export const isNetworkError = (error: unknown): boolean => {
|
||||
return isApiError(error) && error.status === 0;
|
||||
};
|
||||
|
||||
export const isAuthError = (error: unknown): boolean => {
|
||||
return isApiError(error) && error.status === 401;
|
||||
};
|
||||
|
||||
export const isClientError = (error: unknown): boolean => {
|
||||
return isApiError(error) && (error.status ?? 0) >= 400 && (error.status ?? 0) < 500;
|
||||
};
|
||||
|
||||
export const isServerError = (error: unknown): boolean => {
|
||||
return isApiError(error) && (error.status ?? 0) >= 500;
|
||||
};
|
||||
|
||||
export const isTimeoutError = (error: unknown): boolean => {
|
||||
return isApiError(error) && error.status === 408;
|
||||
};
|
||||
|
||||
// Safe request wrapper that returns Result pattern
|
||||
export type Result<T, E = ApiError> = { success: true; data: T } | { success: false; error: E };
|
||||
|
||||
// Generic safe request wrapper
|
||||
export const safeRequest = async <T = any>(
|
||||
requestFn: () => Promise<Response>
|
||||
): Promise<Result<T>> => {
|
||||
try {
|
||||
const response = await requestFn();
|
||||
const data = await handleJsonResponse<T>(response);
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
if (isApiError(error)) {
|
||||
return { success: false, error };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: new ApiError('Unexpected error', 0, 'Unknown Error')
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Safe request wrapper for text responses
|
||||
export const safeRequestText = async (
|
||||
requestFn: () => Promise<Response>
|
||||
): Promise<Result<string>> => {
|
||||
try {
|
||||
const response = await requestFn();
|
||||
const data = await handleTextResponse(response);
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
if (isApiError(error)) {
|
||||
return { success: false, error };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: new ApiError('Unexpected error', 0, 'Unknown Error')
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Safe request wrapper that returns the raw response
|
||||
export const safeRequestRaw = async (
|
||||
requestFn: () => Promise<Response>
|
||||
): Promise<Result<Response>> => {
|
||||
try {
|
||||
const response = await requestFn();
|
||||
return { success: true, data: response };
|
||||
} catch (error) {
|
||||
if (isApiError(error)) {
|
||||
return { success: false, error };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: new ApiError('Unexpected error', 0, 'Unknown Error')
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -11,9 +11,9 @@ import type {
|
||||
} from '$lib/utils/types';
|
||||
import type { PostgrestError, PostgrestSingleResponse } from '@supabase/supabase-js';
|
||||
|
||||
import { GenericUploader } from './presign';
|
||||
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';
|
||||
@@ -53,12 +53,10 @@ export async function fetchProfileCourseProgress(
|
||||
data: ProfileCourseProgress[] | null;
|
||||
error: PostgrestError | null;
|
||||
}> {
|
||||
const { data, error } = await supabase
|
||||
.rpc('get_course_progress', {
|
||||
const { data, error } = await supabase.rpc('get_course_progress', {
|
||||
course_id_arg: courseId,
|
||||
profile_id_arg: profileId
|
||||
})
|
||||
.returns<ProfileCourseProgress[]>();
|
||||
});
|
||||
|
||||
return { data, error };
|
||||
}
|
||||
@@ -286,23 +284,30 @@ export async function fetchLesson(lessonId: Lesson['id']) {
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
const keys = data.videos.filter((video) => video.type === 'upload').map((video) => video.key);
|
||||
const videoKeys =
|
||||
data.videos?.filter((video) => video.type === 'upload')?.map((video) => video.key) || [];
|
||||
|
||||
if (!keys.length) {
|
||||
return { data, error };
|
||||
}
|
||||
const docKeys = data.documents?.map((doc) => doc.key) || [];
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/course/presign/download', { keys });
|
||||
// Get presigned URLs for videos and documents
|
||||
const genericUploader = new GenericUploader('generic');
|
||||
|
||||
const urls = await genericUploader.getAllDownloadPresignedUrl(videoKeys, docKeys);
|
||||
|
||||
data.videos = data.videos.map((video) => {
|
||||
if (video.type === 'upload') {
|
||||
video.link = response.data.urls[video.key];
|
||||
if (urls.videos[video.key]) {
|
||||
video.link = urls.videos[video.key];
|
||||
}
|
||||
return video;
|
||||
});
|
||||
|
||||
data.documents = data.documents.map((doc) => {
|
||||
doc.link = urls.documents[doc.key];
|
||||
return doc;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error retrieving videos:', error);
|
||||
console.error('Error retrieving presigned assets (videos and documents):', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { type Writable, get } from 'svelte/store';
|
||||
import {
|
||||
lessonDocUpload,
|
||||
lessonVideoUpload
|
||||
} from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
|
||||
import axios from 'axios';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
|
||||
export type UploadType = 'document' | 'video' | 'generic';
|
||||
|
||||
export class GenericUploader {
|
||||
public abortController: AbortController | null = null;
|
||||
private uploadType: UploadType;
|
||||
private uploadStore: Writable<any>;
|
||||
|
||||
constructor(uploadType: UploadType) {
|
||||
this.uploadType = uploadType;
|
||||
this.uploadStore = uploadType === 'document' ? lessonDocUpload : lessonVideoUpload;
|
||||
this.abortController = new AbortController();
|
||||
}
|
||||
|
||||
async getDownloadPresignedUrl(keys: string[], type = this.uploadType) {
|
||||
const endpoint =
|
||||
type === 'document'
|
||||
? classroomio.course.presign.document.download
|
||||
: classroomio.course.presign.video.download;
|
||||
|
||||
const response = await endpoint.$post({
|
||||
json: {
|
||||
keys
|
||||
}
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getAllDownloadPresignedUrl(videoKeys: string[], docKeys: string[]) {
|
||||
const urls = {
|
||||
videos: {},
|
||||
documents: {}
|
||||
};
|
||||
|
||||
try {
|
||||
if (videoKeys.length) {
|
||||
const videoResponse = await this.getDownloadPresignedUrl(videoKeys, 'video');
|
||||
urls.videos = videoResponse?.urls || {};
|
||||
}
|
||||
|
||||
if (docKeys.length) {
|
||||
const docResponse = await this.getDownloadPresignedUrl(docKeys, 'document');
|
||||
urls.documents = docResponse?.urls || {};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting download presigned url:', error);
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
async getPresignedUrl(file: File) {
|
||||
const endpoint =
|
||||
this.uploadType === 'document'
|
||||
? classroomio.course.presign.document.upload
|
||||
: classroomio.course.presign.video.upload;
|
||||
|
||||
const response = await endpoint.$post({
|
||||
json: {
|
||||
fileName: file?.name,
|
||||
fileType: file?.type
|
||||
}
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async uploadFile(params: { url: string; file: File }) {
|
||||
await axios.put(params.url, params.file, {
|
||||
headers: {
|
||||
'Content-Type': params.file.type
|
||||
},
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
signal: this.abortController?.signal,
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (get(this.uploadStore).isCancelled) {
|
||||
this.abortController?.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 1));
|
||||
this.uploadStore.update((state) => ({
|
||||
...state,
|
||||
uploadProgress: progress
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initUpload() {
|
||||
this.uploadStore.update((state) => ({
|
||||
...state,
|
||||
isUploading: true,
|
||||
uploadProgress: 0,
|
||||
error: null,
|
||||
isCancelled: false
|
||||
}));
|
||||
|
||||
this.abortController = new AbortController();
|
||||
}
|
||||
|
||||
cancelUpload() {
|
||||
this.uploadStore.update((store) => ({
|
||||
...store,
|
||||
isCancelled: true,
|
||||
isUploading: false
|
||||
}));
|
||||
|
||||
this.abortController?.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
export class DocumentUploader extends GenericUploader {
|
||||
constructor() {
|
||||
super('document');
|
||||
}
|
||||
}
|
||||
|
||||
export class VideoUploader extends GenericUploader {
|
||||
constructor() {
|
||||
super('video');
|
||||
}
|
||||
}
|
||||
@@ -789,7 +789,8 @@
|
||||
"loading_pdf": "PDF laden ...",
|
||||
"failed_to_load_pdf": "Das PDF -Dokument konnte nicht geladen werden",
|
||||
"failed_to_render_pdf": "Die PDF -Seite versäumte es, PDF -Seite zu rendern",
|
||||
"use_arrow_keys": "Verwenden Sie Pfeiltasten, um zu navigieren, +/-, um zu zoomen, oder klicken Sie auf die obigen Steuerelemente. Mit der rechten Maustaste ist deaktiviert."
|
||||
"use_arrow_keys": "Verwenden Sie Pfeiltasten, um zu navigieren, +/-, um zu zoomen, oder klicken Sie auf die obigen Steuerelemente. Mit der rechten Maustaste ist deaktiviert.",
|
||||
"upgrade": "Aktualisieren Sie Ihren Plan, um Dokumente hochzuladen"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1409,7 +1410,8 @@
|
||||
},
|
||||
"generic": {
|
||||
"locked": "Gesperrt",
|
||||
"unlocked": "Entsperrt"
|
||||
"unlocked": "Entsperrt",
|
||||
"loading": "Laden"
|
||||
},
|
||||
"specialization": {
|
||||
"course_tag": "Spezialisierung"
|
||||
|
||||
@@ -693,6 +693,7 @@
|
||||
"upload_error_message": "Unsupported file format or upload failed",
|
||||
"upload_error_try_again": "Please try again with a valid document file",
|
||||
"try_again": "Try Again",
|
||||
"upgrade": "Upgrade your plan to upload documents",
|
||||
|
||||
"cancel_upload": "Cancel Upload",
|
||||
"upload_cancelled": "Upload cancelled by user",
|
||||
@@ -1521,7 +1522,8 @@
|
||||
|
||||
"generic": {
|
||||
"locked": "Locked",
|
||||
"unlocked": "Unlocked"
|
||||
"unlocked": "Unlocked",
|
||||
"loading": "Loading"
|
||||
},
|
||||
|
||||
"validations": {
|
||||
|
||||
@@ -789,7 +789,8 @@
|
||||
"loading_pdf": "Cargando PDF ...",
|
||||
"failed_to_load_pdf": "No se pudo cargar el documento PDF",
|
||||
"failed_to_render_pdf": "No se pudo renderizar la página de PDF",
|
||||
"use_arrow_keys": "Use las teclas de flecha para navegar, +/- para hacer zoom o hacer clic en los controles anteriores. El clic derecho está deshabilitado."
|
||||
"use_arrow_keys": "Use las teclas de flecha para navegar, +/- para hacer zoom o hacer clic en los controles anteriores. El clic derecho está deshabilitado.",
|
||||
"upgrade": "Actualice su plan para cargar documentos"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1409,7 +1410,8 @@
|
||||
},
|
||||
"generic": {
|
||||
"locked": "bloqueado",
|
||||
"unlocked": "desbloqueado"
|
||||
"unlocked": "desbloqueado",
|
||||
"loading": "Cargando"
|
||||
},
|
||||
"specialization": {
|
||||
"course_tag": "Especialización"
|
||||
|
||||
@@ -789,7 +789,8 @@
|
||||
"loading_pdf": "Chargement du PDF ...",
|
||||
"failed_to_load_pdf": "Échec du chargement du document PDF",
|
||||
"failed_to_render_pdf": "Échec de la page PDF",
|
||||
"use_arrow_keys": "Utilisez des touches flèches pour naviguer, +/- pour zoomer ou cliquez sur les commandes ci-dessus. Le clic droit est désactivé."
|
||||
"use_arrow_keys": "Utilisez des touches flèches pour naviguer, +/- pour zoomer ou cliquez sur les commandes ci-dessus. Le clic droit est désactivé.",
|
||||
"upgrade": "Mettez à niveau votre plan pour télécharger des documents"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1409,7 +1410,8 @@
|
||||
},
|
||||
"generic": {
|
||||
"locked": "Fermé à clé",
|
||||
"unlocked": "Débloqué"
|
||||
"unlocked": "Débloqué",
|
||||
"loading": "Chargement"
|
||||
},
|
||||
"specialization": {
|
||||
"course_tag": "Spécialisation"
|
||||
|
||||
@@ -789,7 +789,8 @@
|
||||
"loading_pdf": "लोडिंग पीडीएफ ...",
|
||||
"failed_to_load_pdf": "पीडीएफ दस्तावेज़ लोड करने में विफल",
|
||||
"failed_to_render_pdf": "पीडीएफ पेज को प्रस्तुत करने में विफल",
|
||||
"use_arrow_keys": "ज़ूम करने के लिए नेविगेट करने के लिए तीर कुंजियों का उपयोग करें, या ऊपर दिए गए नियंत्रणों पर क्लिक करें। राइट-क्लिक अक्षम है।"
|
||||
"use_arrow_keys": "ज़ूम करने के लिए नेविगेट करने के लिए तीर कुंजियों का उपयोग करें, या ऊपर दिए गए नियंत्रणों पर क्लिक करें। राइट-क्लिक अक्षम है।",
|
||||
"upgrade": "दस्तावेज़ अपलोड करने के लिए अपनी योजना को अपग्रेड करें"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1409,7 +1410,8 @@
|
||||
},
|
||||
"generic": {
|
||||
"locked": "बंद",
|
||||
"unlocked": "अनलॉक किया"
|
||||
"unlocked": "अनलॉक किया",
|
||||
"loading": "लोड करना"
|
||||
},
|
||||
"specialization": {
|
||||
"course_tag": "विशेषज्ञता"
|
||||
|
||||
@@ -99,7 +99,8 @@
|
||||
},
|
||||
"generic": {
|
||||
"locked": "Zamknięty",
|
||||
"unlocked": "Odblokowany"
|
||||
"unlocked": "Odblokowany",
|
||||
"loading": "Załadunek"
|
||||
},
|
||||
"content": {
|
||||
"heading": "Internacjonalizacja w SvelTekit",
|
||||
@@ -957,7 +958,8 @@
|
||||
"loading_pdf": "Ładowanie PDF ...",
|
||||
"failed_to_load_pdf": "Nie udało się załadować dokumentu PDF",
|
||||
"failed_to_render_pdf": "Nie udało się renderować strony PDF",
|
||||
"use_arrow_keys": "Użyj klawiszy strzałek do nawigacji, +/-, aby powiększyć lub kliknąć powyższe elementy sterujące. Kliknięcie prawym przyciskiem myszy jest wyłączone."
|
||||
"use_arrow_keys": "Użyj klawiszy strzałek do nawigacji, +/-, aby powiększyć lub kliknąć powyższe elementy sterujące. Kliknięcie prawym przyciskiem myszy jest wyłączone.",
|
||||
"upgrade": "Uaktualnij swój plan przesyłania dokumentów"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -789,7 +789,8 @@
|
||||
"loading_pdf": "Carregando PDF ...",
|
||||
"failed_to_load_pdf": "Falha ao carregar o documento PDF",
|
||||
"failed_to_render_pdf": "Falhou em renderizar a página em PDF",
|
||||
"use_arrow_keys": "Use as teclas de seta para navegar, +/- zoom ou clique nos controles acima. Clique com o botão direito do mouse está desativado."
|
||||
"use_arrow_keys": "Use as teclas de seta para navegar, +/- zoom ou clique nos controles acima. Clique com o botão direito do mouse está desativado.",
|
||||
"upgrade": "Atualize seu plano para fazer upload de documentos"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1409,7 +1410,8 @@
|
||||
},
|
||||
"generic": {
|
||||
"locked": "Bloqueado",
|
||||
"unlocked": "Desbloqueado"
|
||||
"unlocked": "Desbloqueado",
|
||||
"loading": "Carregando"
|
||||
},
|
||||
"specialization": {
|
||||
"course_tag": "Especialização"
|
||||
|
||||
@@ -789,7 +789,8 @@
|
||||
"loading_pdf": "Загрузка PDF ...",
|
||||
"failed_to_load_pdf": "Не удалось загрузить документ PDF",
|
||||
"failed_to_render_pdf": "Не удалось отобразить страницу PDF",
|
||||
"use_arrow_keys": "Используйте клавиши со стрелками, чтобы перемещаться, +/- до масштабирования или нажмите кнопку элементов управления выше. Щелкнуть правой кнопкой мыши отключено."
|
||||
"use_arrow_keys": "Используйте клавиши со стрелками, чтобы перемещаться, +/- до масштабирования или нажмите кнопку элементов управления выше. Щелкнуть правой кнопкой мыши отключено.",
|
||||
"upgrade": "Обновите свой план для загрузки документов"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1409,7 +1410,8 @@
|
||||
},
|
||||
"generic": {
|
||||
"locked": "Заблокировано",
|
||||
"unlocked": "Разблокировано"
|
||||
"unlocked": "Разблокировано",
|
||||
"loading": "Загрузка"
|
||||
},
|
||||
"specialization": {
|
||||
"course_tag": "Специализация"
|
||||
|
||||
@@ -789,7 +789,8 @@
|
||||
"loading_pdf": "Tải PDF ...",
|
||||
"failed_to_load_pdf": "Không tải tài liệu PDF",
|
||||
"failed_to_render_pdf": "Không thể hiển thị trang PDF",
|
||||
"use_arrow_keys": "Sử dụng các phím mũi tên để điều hướng, +/- đến phóng to hoặc nhấp vào các điều khiển ở trên. Nhấp chuột phải bị vô hiệu hóa."
|
||||
"use_arrow_keys": "Sử dụng các phím mũi tên để điều hướng, +/- đến phóng to hoặc nhấp vào các điều khiển ở trên. Nhấp chuột phải bị vô hiệu hóa.",
|
||||
"upgrade": "Nâng cấp kế hoạch của bạn để tải lên tài liệu"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1409,7 +1410,8 @@
|
||||
},
|
||||
"generic": {
|
||||
"locked": "Đã khóa",
|
||||
"unlocked": "Đã mở khóa"
|
||||
"unlocked": "Đã mở khóa",
|
||||
"loading": "Đang tải"
|
||||
},
|
||||
"specialization": {
|
||||
"course_tag": "Chuyên môn"
|
||||
|
||||
@@ -295,7 +295,7 @@ export type LessonDocument = {
|
||||
name: string;
|
||||
link: string;
|
||||
size?: number;
|
||||
key?: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
export interface LessonPage {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
|
||||
const sendEmail = (sFetch: typeof fetch) => {
|
||||
return async (
|
||||
@@ -12,13 +12,10 @@ const sendEmail = (sFetch: typeof fetch) => {
|
||||
}[]
|
||||
) => {
|
||||
try {
|
||||
const response = await sFetch(`${env.PUBLIC_SERVER_URL}/mail/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(emailDataArray)
|
||||
});
|
||||
const response = await classroomio.mail.send.$post(
|
||||
{ json: emailDataArray },
|
||||
{ fetch: sFetch }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.log('Failed to send emails');
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
import { ChevronLeft, ChevronRight, Edit, Save } from 'carbon-icons-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';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
|
||||
export let data;
|
||||
|
||||
@@ -160,9 +160,8 @@
|
||||
const lessonNumber = getLessonOrder(currentLesson.id);
|
||||
const slideUrl = $lesson.materials.slide_url || '';
|
||||
|
||||
const response = await apiClient.post(
|
||||
'/course/lesson/download/pdf',
|
||||
{
|
||||
const response = await classroomio.course.lesson.download.pdf.$post({
|
||||
json: {
|
||||
title: currentLesson.title,
|
||||
number: lessonNumber,
|
||||
orgName: $currentOrg.name,
|
||||
@@ -170,13 +169,11 @@
|
||||
slideUrl: slideUrl,
|
||||
video: lessonVideo,
|
||||
courseTitle: $course.title
|
||||
},
|
||||
{
|
||||
responseType: 'blob'
|
||||
}
|
||||
);
|
||||
});
|
||||
const blob = await response.blob();
|
||||
|
||||
const file = new Blob([response.data], { type: 'application/pdf' });
|
||||
const file = new Blob([blob], { type: 'application/pdf' });
|
||||
const fileURL = URL.createObjectURL(file);
|
||||
|
||||
let a = document.createElement('a');
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config}*/
|
||||
const config = {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
|
||||
plugins: []
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
// generic name for component
|
||||
// add theme-name dynamically on body
|
||||
// have globaly style for theme-name .bg-primary-100
|
||||
@@ -11,7 +11,7 @@
|
||||
"strict": true,
|
||||
"target": "ESNext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"noImplicitAny": false,
|
||||
"paths": {
|
||||
"$lib": ["./src/lib"],
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import fs from 'fs';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
|
||||
export default ({ mode }) => {
|
||||
process.env = { ...process.env, ...loadEnv(mode, process.cwd()) };
|
||||
|
||||
@@ -19,10 +21,14 @@ export default ({ mode }) => {
|
||||
sourcemap: false
|
||||
},
|
||||
optimizeDeps: {
|
||||
entries: ['src/routes/**/+*.{js,ts,svelte}']
|
||||
entries: ['src/routes/**/+*.{js,ts,svelte}'],
|
||||
include: ['@cio/api/rpc-types']
|
||||
},
|
||||
resolve: {
|
||||
mainFields: ['browser']
|
||||
mainFields: ['browser'],
|
||||
alias: {
|
||||
'@cio/api': path.resolve(__dirname, '../api/dist')
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,4 +4,4 @@ import { Callout } from 'nextra/components';
|
||||
|
||||
# Cloudflare Setup
|
||||
|
||||
In this guide, you will learn how to get your cloudflare environment variables. We use Cloudflare R2 for video uploads and the aim of this guide is to help you get the right variables to properly setup our backend.
|
||||
In this guide, you will learn how to get your cloudflare environment variables. We use Cloudflare R2 for video uploads and the aim of this guide is to help you get the right variables to properly setup our api.
|
||||
|
||||
@@ -10,19 +10,19 @@ This guide will help you quickly selfhost ClassroomIO
|
||||
|
||||
ClassroomIO is made up of the following applications
|
||||
|
||||
- [apps/backend](https://github.com/rotimi-best/classroomio/tree/main/apps/backend) - an Express server
|
||||
- [apps/api](https://github.com/rotimi-best/classroomio/tree/main/apps/api) - an Express server
|
||||
- [apps/dashboard](https://github.com/rotimi-best/classroomio/tree/main/apps/dashboard) - the admin dashboard + LMS
|
||||
- [supabase](https://github.com/rotimi-best/classroomio/tree/main/supabase) - the database + REST API.
|
||||
|
||||
The quickest and easiest way to deploy both the backend and dashboard is by using our [Railway Template](https://railway.app/template/dl9RG7?referralCode=8OyIj5). However there are a couple of things that you'd need first before heading over to the template, which are:
|
||||
The quickest and easiest way to deploy both the api and dashboard is by using our [Railway Template](https://railway.app/template/dl9RG7?referralCode=8OyIj5). However there are a couple of things that you'd need first before heading over to the template, which are:
|
||||
|
||||
1. SMTP values - For sending emails on the backend
|
||||
1. SMTP values - For sending emails on the api
|
||||
2. Supabase - For the database used on the dashboard
|
||||
3. Cloudflare R2 (optional) - Video uploads on the backend
|
||||
3. Cloudflare R2 (optional) - Video uploads on the api
|
||||
|
||||
## SMTP Setup
|
||||
|
||||
Both for the supabase and backend setup, you'd need a custom SMTP for email sending. Most importantly you need these values:
|
||||
Both for the supabase and api setup, you'd need a custom SMTP for email sending. Most importantly you need these values:
|
||||
|
||||
```bash
|
||||
SMTP_HOST=
|
||||
@@ -63,7 +63,7 @@ We use Cloudflare R2 for video uploading within the course builder. It is not re
|
||||
|
||||
## Deploy on Railway
|
||||
|
||||
Now you have everything you need and you are ready to deploy the backend and dashboard.
|
||||
Now you have everything you need and you are ready to deploy the api and dashboard.
|
||||
|
||||
Go ahead and click this button to get started.
|
||||
|
||||
@@ -71,7 +71,7 @@ Go ahead and click this button to get started.
|
||||
|
||||
### Environment Variables.
|
||||
|
||||
#### cio-backend
|
||||
#### cio-api
|
||||
|
||||
```bash
|
||||
# Required
|
||||
@@ -94,7 +94,7 @@ SENTRY_DNS=""
|
||||
|
||||
<Callout type="info" emoji="ℹ️">
|
||||
Learn more about the environment variables
|
||||
[here](https://github.com/rotimi-best/classroomio/blob/main/apps/backend/.env.example)
|
||||
[here](https://github.com/rotimi-best/classroomio/blob/main/apps/api/.env.example)
|
||||
</Callout>
|
||||
|
||||
#### cio-dashboard
|
||||
@@ -121,7 +121,7 @@ PRIVATE_APP_SUBDOMAINS=""
|
||||
|
||||
## Using Docker
|
||||
|
||||
We provide a docker-compose configuration to deploy both the backend and dashboard services. Here's how to use it:
|
||||
We provide a docker-compose configuration to deploy both the api and dashboard services. Here's how to use it:
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
@@ -151,7 +151,7 @@ The docker-compose file sets up two services:
|
||||
|
||||
- Runs on port 3002
|
||||
- Uses Node.js slim image
|
||||
- Mounts the backend code directory
|
||||
- Mounts the api code directory
|
||||
- Requires environment variables for:
|
||||
- Supabase configuration
|
||||
- Cloudflare R2 for video uploads
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user