feat: complete rewrite of course (#622)

* theming improvements

* border style improvements

* refactor: migrate org routes to server-side data fetching

Move client-side API calls to server-side load functions for better
performance, security, and SEO. Implement permission-based course filtering

* refactor: clean up course and community components

This commit removes unused components and files related to courses and community features, streamlining the codebase. It also updates various routes to enhance performance and maintainability. Additionally, it introduces new types and constants for better type safety and organization.

* refactor: fix video formatting on website and other styles

* refactor: update course and dashboard components for improved styling and functionality

* more icon improvements

* refactor: streamline course and dashboard components

This commit updates various course and dashboard components for improved styling and functionality. It includes the removal of unused files, updates to imports and exports, and enhancements to type safety. Additionally, it introduces new routes and optimizes existing ones for better performance and maintainability.

* refactor: enhance lesson completion logic

* chore: delete all emails on dashboard and move other api routes to backend.

move all emails to the backend and

* feat: Refactor API and database queries for courses, quizzes, and lessons, migrating Supabase functions and schema management.

* get app loading

* fix ui and build error of cio/api

* refactor: move direct database queries to query layer

- Create query functions: getCourseWithOrgData, getOrganizationByCourseId, updateCourseVersion, updateLessonsSectionId, getProfileByGroupMemberId, getQuestionTypesByIds
- Refactor services to use query functions instead of direct db queries
- Add transaction support to updateCourseVersion and updateLessonsSectionId
- Fix getProfileByGroupMemberId to return profile directly (not nested)
- Fix updateLessonsSectionId to use .returning() instead of rowCount

This aligns services with architecture pattern where services orchestrate queries rather than executing them directly.

* exercise saving improvements

* fix and optimise exercise creation

* refactor: update dialog components for improved styling and functionality

- Adjusted dialog content width and maximum constraints for better responsiveness.
- Changed heading font size for consistency in the new exercise modal.
- Replaced CloseIcon with CrossIcon for a more modern appearance in dialog close button.

* refactor: enhance course and exercise management components

- Introduced new components for exercise creation, editing, and viewing, improving user experience.
- Refactored existing lesson and exercise components for better organization and maintainability.
- Updated routing for exercises and lessons to streamline navigation within the course structure.
- Added new utility functions and constants to support content management across the application.

* feat: implement course content management API and UI components

- Added new API routes for updating and deleting course content, enhancing content management capabilities.
- Introduced new Svelte components for displaying and managing course content, including content lists and action handlers.
- Updated existing course routing to integrate new content management features.
- Refactored content-related utility functions for improved organization and maintainability.

* refactor: remove Supabase references and implement course section management

- Removed Supabase-related environment variables and Dockerfile arguments to streamline configuration.
- Introduced a new section management API with routes for creating, updating, deleting, and reordering course sections.
- Updated course services to handle section operations, ensuring consistency in course content management.
- Refactored existing lesson section logic to align with the new course section structure, enhancing maintainability.

* chore: update .prettierignore to exclude additional file types

- Added exclusions for SQL and environment files to the Prettier ignore list.
- Included Dockerfile and .prettierignore itself to prevent formatting issues.

* feat: enhance course content management with translation support and UI improvements

- Added translation keys for user-facing text in course-related components, ensuring all UI copy is sourced from translation files.
- Updated lesson and exercise creation components to utilize translation keys for success and error messages, improving localization.
- Refactored course content management components to streamline the creation and editing process, enhancing user experience.
- Introduced new constants for content options and stepper states to standardize component behavior and improve maintainability.
- Improved styling and functionality of course content modals and steppers for better user interaction.

* feat: enhance course content management and UI components

- Updated .prettierignore to exclude storybook-static directory.
- Improved exercise creation logic by refining option handling and adding UUID support for option values.
- Enhanced navigation component with debugging output for custom links.
- Introduced a new method in CourseApi to update content items locally, improving state management during navigation.
- Added caching for exercise templates by tag in ExerciseTemplateApi to optimize performance.
- Refactored lesson and exercise components for better organization and maintainability, including updates to modal styling and functionality.
- Implemented local patching for lesson and exercise list items to ensure UI consistency after updates.

* student + teacher invite (not fully tested)

* refactor: streamline course team member middleware

- Removed unnecessary console logging and timing for checking user permissions.
- Simplified the logic for determining if a user is a course team member or organization admin.

* reorder course sidebar to move content to its own page.

* feat: implement unified course enrollment route and API

- Introduced a new enrollment route `/course/:slug/enroll` to replace the previous invite system.
- Unified enrollment logic for free and paid courses, allowing direct enrollment for free courses and invite token validation for paid courses.
- Updated course landing page to redirect to the new enrollment route for free courses.
- Removed obsolete invite routes and related logic, streamlining the enrollment process.
- Enhanced API with a new `POST /course/:courseId/enroll` endpoint for handling enrollments.
- Added rate limiting for enrollment requests to prevent abuse.
- Updated UI components to reflect changes in enrollment flow and messaging.

* chore: update docker stack, docs, and auth token automation

* chore: make prettier check ignore unknown file types

* fix: avoid using authClient on server file and keep authClient client focused

* chore: always get the server on demand depending on the environment

* chore: update dashboard configuration and Docker setup for environment variables

- Modified the Svelte configuration to directly use the PUBLIC_SERVER_URL environment variable.
- Enhanced the Docker Compose file to include health check formatting and added environment variable arguments for the dashboard build.
- Updated the Dockerfile to echo environment variable values during the build process, ensuring clarity on the configuration being used.

* chore: enhance Docker setup and documentation for production deployment

- Added a new production Docker Compose file and example environment file for published images.
- Updated README and usage documentation to include instructions for deploying with production images.
- Modified Svelte configuration to standardize the use of the posthog domain.
- Removed unnecessary console logs from the API client and layout server files for cleaner output.
- Improved overall clarity in Docker documentation regarding self-hosting and image publishing.

* chore(db): log function name in query catch blocks (console.error 'funcName error:', error)

Co-authored-by: Cursor <[email protected]>

* fix: multiple refresh issue

* fix: server key not passed to api

* fix: clarify usage of PRIVATE_SERVER_KEY and AUTH_BEARER_TOKEN in documentation

- Updated documentation to specify that PRIVATE_SERVER_KEY is used for server-to-server API key authentication and is validated by API key middleware.
- Clarified that AUTH_BEARER_TOKEN is a separate token not utilized by apiKeyMiddleware.
- Adjusted examples in Docker setup and self-hosting documentation to reflect these changes.

* fix migration script

* fix: improve language handling and add database reset script

- Updated language extraction to handle potential undefined values, defaulting to 'en' if necessary.
- Added a new script for resetting the database, including functionality to truncate public tables and run setup with optional seeding.

* feat: add promote ungrouped section functionality and enhance document handling in dashboard

- Implemented a new API endpoint to promote synthetic "Ungrouped" content into a real persisted section, allowing for better organization of course materials.
- Updated the dashboard to include a new component for displaying content count badges for lessons and exercises.
- Enhanced document management features, including a new document card component with dropdown actions for viewing, downloading, and removing documents.
- Improved file upload handling with a dedicated file drop zone and error handling for file type and size restrictions.
- Refactored existing components for better readability and maintainability, including updates to lesson and document lists.

* chore: use root env file for docker compose

* docs: document docker no-build startup flow

* chore: add no-build option to docker full stack script

* fix(email): handle SMTP TLS mode by port

* feat(auth): add configurable cookie domain env and docs

* apps/dashboard/src/lib/utils/services/auth/session.ts

* fix(email): make sender configurable via SMTP_SENDER

* chore(i18n): regenerate locale translations and support language targeting

* chore(config): switch api to PUBLIC_SERVER_URL

* fix(i18n): persist selected locale across navigation

* feat(assets): implement organization asset management routes and services

- Added new routes for managing organization assets, including listing, creating, updating, deleting, and exporting assets.
- Introduced asset-related services for handling asset operations such as creation, retrieval, and deletion.
- Integrated YouTube metadata resolution and asset attachment/detachment functionalities.
- Updated organization routes to include the new asset management routes.
- Enhanced error handling for asset-related operations.

* fix(document-list): update conditional rendering for edit mode in document list

* debug self hosted layout setup

* refactor(sidebar): simplify lms sidebar footer and improve navigation item rendering

* feat: tags

* chore: tags roadmap + translations

* fix(api): add authMiddleware to exercise, invites, and content routers

- exerciseRouter: apply authMiddleware before courseMemberMiddleware on all 10 routes
- invitesRouter: apply authMiddleware before courseTeamMemberMiddleware on all 4 routes
- contentRouter: add authMiddleware to PUT route (DELETE already had it)

Both courseMemberMiddleware and courseTeamMemberMiddleware document requiring
authMiddleware to be applied first. This aligns with lessonRouter pattern and
ensures explicit middleware contract regardless of global session middleware.

Co-authored-by: Rotimi Best <[email protected]>

* fix(course): use courseTeamMemberMiddleware for PUT, DELETE, and analytics routes

These routes require admin/tutor role per JSDoc but were using
courseMemberMiddleware which allows any course member including students.
Switch to courseTeamMemberMiddleware to restrict access to ADMIN/TUTOR
group members and org admins.

Co-authored-by: Rotimi Best <[email protected]>

* chore: unify pricing component between dashboard and website (#630)

* feat: modularize pricing card

* feat: initia implementation of pricing togge

* fix: minor amendment

* refactor: update pricing components for improved layout and styling

- Adjusted spacing in the pricing card layout for better visual consistency.
- Modified button styles and sizes for enhanced usability.
- Cleaned up the pricing toggle component by removing unnecessary console logs and restructuring the button rendering logic.

* add handle click

* chore(css): update Tailwind CSS output with new utility classes and theme variables

* fix(api): enable orgMemberMiddleware for stats route

This change ensures that the stats route is protected by orgMemberMiddleware, aligning access control with other routes. Additionally, removed unnecessary console log from the layout server file to clean up the code.

---------

Co-authored-by: Rotimi Best <[email protected]>

* pass query parameter to signup from pricing page

* ui updates to pricing cards

* commit all PRDs

* add open api spec to api prd

* make blue default theme

* more prds

* sync option id sequence on question update

* docs: more classroomio features

* chore: more ui improvements

* docs: new component docs

* chore: CSP Configuration (#638)

* feat(validation): add GitHub repository name validation

- Add ZRepositoryName schema for validating GitHub repo names
- Add ZRepositoryOwner for owner/username validation
- Add ZRepositoryIdentifier for owner/repo format
- Follow GitHub rules: A-Za-z0-9_.-, 1-100 chars, reserved names
- Add translation keys for validation messages

Fixes #637

Co-authored-by: Rotimi Best <[email protected]>

* Revert "feat(validation): add GitHub repository name validation"

This reverts commit 3436536bb17a0f05b3a9fe8dd7682cde64dd1dce.

* feat(dashboard): make CSP external domains configurable via env

Move hardcoded external domains from svelte.config.js to .env configuration
for on-premise deployments with strict security policies, data residency
constraints, or CISO-driven allowlists.

- ALLOWED_EXTERNAL_DOMAINS: single list for all CSP directives
- CSP_SCRIPT_SRC_DOMAINS, CSP_STYLE_SRC_DOMAINS, CSP_CONNECT_SRC_DOMAINS,
  CSP_FRAME_SRC_DOMAINS, CSP_FONT_SRC_DOMAINS: per-directive override
- When unset, defaults retain current SaaS behavior (backward compatible)

Fixes #637

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): self-hosted CSP uses only env vars, no defaults

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): add all classroomio.com domains to CSP defaults

Co-authored-by: Rotimi Best <[email protected]>

* docs: add CSP ALLOWED_EXTERNAL_DOMAINS to self-hosting guides

Co-authored-by: Rotimi Best <[email protected]>

* feat(docker): pass CSP domain env vars as build args to dashboard

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>

* feat: add SSO and token authentication routes and services

- Introduced new routes for SSO and token authentication under the organization namespace.
- Implemented SSO connection management including creation, update, activation, and deletion.
- Added token authentication configuration with support for secret rotation and status retrieval.
- Enhanced error handling for SSO and token authentication processes.
- Updated existing routes and services to integrate new functionalities.
- Improved gradebook service to fetch student marks and exercises.
- Refactored API structure for better organization and maintainability.

* update translations

* fix: add PUBLIC_SERVER_URL to .env.example to prevent localhost fallback in production (#641)

Co-authored-by: Cursor Agent <[email protected]>

* feat: new exercise question types

* MinIO S3 storage backend (#639)

* feat(storage): add MinIO/S3-compatible object storage backend

- Add OBJECT_STORAGE_* env vars for generic S3-compatible backends (MinIO, AWS S3)
- Create storage config module with Cloudflare R2 fallback for backward compatibility
- Refactor s3.ts to use configurable client and bucket names from config
- Update media.ts to use provider-neutral public URL (OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL or CLOUDFLARE_IMAGE_BUCKET_DOMAIN)
- Add MinIO service and init to docker-compose (profile: minio) with videos/documents/media buckets
- Add localhost:9000 to dashboard CSP for local MinIO presigned URL uploads
- Update self-hosting and Cloudflare docs with MinIO option
- Update .env examples and docker USAGE.md

Fixes #635

Co-authored-by: Rotimi Best <[email protected]>

* docs: add MINIO_ROOT_USER and MINIO_ROOT_PASSWORD to self-hosting guides

Co-authored-by: Rotimi Best <[email protected]>

* feat(media): refactor media management and integrate MinIO support

- Updated media API to use a new `mediaApi` module, replacing `mediaManagerApi` references across components.
- Added MinIO configuration options to the Docker setup, allowing for optional object storage exclusion.
- Enhanced environment variable management for MinIO in the `.env.example` file.
- Updated Content Security Policy (CSP) to allow local MinIO access in the dashboard.
- Introduced new media components for asset management, including asset cards and usage dialogs.

This refactor improves the modularity of the media handling system and provides better support for S3-compatible storage solutions.

* feat(dashboard): enhance logging and refactor auth session handling

- Added console logging for event and environment variables in hooks.server.ts to aid debugging.
- Refactored auth session handling to use authServerClient instead of authClient in session.ts for improved consistency.
- Updated Dockerfile to ensure the dashboard build output is present before starting the application.
- Expanded self-hosting documentation to include troubleshooting steps for disk space issues in PostgreSQL and Docker.

These changes improve the observability of the application and streamline the authentication process.

* refactor(dashboard): remove debug logging and enhance Postgres healthcheck

- Removed console logging for event and environment variables in hooks.server.ts to clean up the code.
- Updated Postgres healthcheck in docker-compose.yaml to require a successful `SELECT 1` query, ensuring dependent services only start once the database is ready.
- Expanded self-hosting documentation to include troubleshooting steps for database recovery issues.

These changes improve code clarity and reliability in service startup sequences.

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>

* feat: add Plyr media player icons and PDF.js integration

- Introduced new Plyr SVG icons for media player controls in both course and dashboard applications.
- Updated Plyr player configuration to use local icon assets.
- Integrated PDF.js for document viewing, replacing external CDN links with local paths for improved performance and reliability.
- Enhanced PDF viewer modal z-index for better visibility.

This update improves the media player experience and ensures consistent access to necessary resources.

* feat: enhance account and organization services with type definitions and promise return types

- Updated `getAccountData` to return a structured result type including profile and organizations.
- Modified `getOrganizationsWithFilters` to specify a promise return type for better type safety.
- Added new type imports for organization-related data structures to improve clarity and maintainability.

These changes improve type safety and enhance the overall structure of the account and organization services.

* feat: implement Redis caching for presigned URLs and refactor API interactions

- Added Redis caching for presigned download URLs in the API to reduce redundant S3 calls, improving performance.
- Introduced a new `PresignApi` class in the frontend to centralize presigned URL requests, enhancing code maintainability.
- Updated various components to utilize the new `presignApi` methods for document and video upload/download operations.
- Enhanced error handling for Redis operations to ensure graceful degradation in case of cache failures.

These changes optimize the presigned URL generation process and streamline API interactions across the application.

* fix: add better-auth dependency to dashboard (#645)

* chore: unify exercise answer data format

* feat: make selfhosting run under one org for simplicity (#647)

* feat: implement self-hosting PRD + license key system

Self-hosting single-org:
- Add PUBLIC_IS_SELFHOSTED to API env, block org creation when org exists
- Auto-add non-members as students in GET /account bootstrap
- getOrgSiteInfo: root domain returns single org for self-hosted
- App init: route to /lms when self-hosted + no orgs
- Hide org switcher and Add Org modal when self-hosted

Invite-only signup:
- Add settings.signup.inviteOnly via ZUpdateOrganization
- Allow public signups toggle in org auth settings
- Signup page: hide form when invite-only and no invite context
- Landing page nav: respect inviteOnly for signup link

First signup auto-verify:
- Self-hosted first signup (no orgs) auto-verifies email in createProfileHook

License key system:
- LICENSE_KEY, LICENSE_VERIFICATION_URL for external verification API
- Feature gates: SSO, token-auth, no-tracking (PostHog/Senja)
- requireLicense middleware for API routes
- Dashboard: license store, gate PostHog/Senja/SSO/token-auth UI
- docs/LICENSE_API_CONTRACT.md for external API spec

Co-authored-by: Rotimi Best <[email protected]>

* feat: get rid of carbon completely

* chore: license verification happens only for selfhosted instance

* fix: in self hosted instance, orgSite is the default

* docs: how to write math in cio editor

* Update packages/db/src/queries/organization/organization.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update apps/api/src/services/license.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix: translations, cursor pr reviews, simplify license fetching and check

* refactor: update analytics setup for cloud and self-hosted deployments; enhance settings merge logic

* refactor: update license fetching URL for development environment; optimize organization query to prevent plan row loss

* more selfhosting improvements

* feat: enhance signup guard for self-hosted instances by requiring organization context when no org ID is provided

* pr reviews

* fix: correct query parameter handling in course navigation

* pr reviews

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
Co-authored-by: Abdulmuizz Hamzat <[email protected]>
Co-authored-by: Adeyemi Adetunji <[email protected]>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2026-03-09 11:18:22 +01:00
committed by GitHub
co-authored by Rotimi Best greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Cursor Agent Abdulmuizz Hamzat Adeyemi Adetunji
parent a42565ad82
commit 1e9a82e912
1196 changed files with 118809 additions and 74705 deletions
@@ -5,19 +5,129 @@
import { authValidation } from '$lib/utils/functions/validator';
import { currentOrg } from '$lib/utils/store/org';
import { authClient } from '$lib/utils/services/auth/client';
import { page } from '$app/state';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { globalStore } from '$lib/utils/store/app';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { Password } from '@cio/ui/custom/password';
import { Button } from '@cio/ui/base/button';
import { ssoApi } from '$features/org/api/sso.svelte';
import ShieldIcon from '@lucide/svelte/icons/shield';
let fields = $state(Object.assign({}, LOGIN_FIELDS));
let submitError: string | undefined = $state();
let loading = $state(false);
let errors = $state(Object.assign({}, LOGIN_FIELDS));
let isSSOInfoLoaded = $state(false);
let orgSupportsSso = $state(false);
// SSO state
let ssoState = $state<{
checking: boolean;
required: boolean;
available: boolean;
redirectUrl: string | null;
providerName: string | null;
providerId: string | null;
}>({
checking: false,
required: false,
available: false,
redirectUrl: null,
providerName: null,
providerId: null
});
const redirectUrl = $derived(page.url.searchParams.get('redirect'));
const hideGoogleAuth = $derived(!!($globalStore.isOrgSite && $currentOrg.disableGoogleAuth));
async function initSSOInfo() {
if (isSSOInfoLoaded || !$currentOrg.id) return;
isSSOInfoLoaded = true;
const ssoInfo = await ssoApi.getOrgSsoInfo($currentOrg.id);
console.log('ssoInfo', ssoInfo);
if (ssoInfo) {
orgSupportsSso = ssoInfo.hasSso;
ssoState = {
checking: false,
required: ssoInfo.ssoRequired,
available: ssoInfo.hasSso,
redirectUrl: ssoInfo.redirectUrl,
providerName: ssoInfo.providerName,
providerId: ssoInfo.providerId
};
}
}
$effect(() => {
initSSOInfo();
});
let emailCheckTimeout: ReturnType<typeof setTimeout>;
function handleEmailChange() {
errors.email = '';
if (!orgSupportsSso) return;
clearTimeout(emailCheckTimeout);
emailCheckTimeout = setTimeout(async () => {
if (!fields.email || !fields.email.includes('@')) {
ssoState = {
checking: false,
required: false,
available: false,
redirectUrl: null,
providerName: null,
providerId: null
};
return;
}
ssoState.checking = true;
const result = await ssoApi.discoverSso(fields.email);
ssoState.checking = false;
if (result) {
ssoState = {
checking: false,
required: result.ssoRequired,
available: result.ssoAvailable,
redirectUrl: result.redirectUrl,
providerName: result.providerName,
providerId: result.providerId
};
}
}, 500);
}
async function handleSsoLogin() {
const url = `${window.location.origin}${ssoState.redirectUrl || ''}`;
const res = await authClient.signIn.sso({
email: fields.email,
providerId: ssoState.providerId || '',
callbackURL: url
});
console.log('res', res);
}
async function handleSubmit() {
if (ssoState.required && ssoState.available) {
handleSsoLogin();
return;
}
if ($globalStore.isOrgSite && $currentOrg.disableEmailPassword) {
submitError = t.get('settings.auth.login.email_password_disabled');
return;
}
const validationRes = authValidation(fields);
console.log('validationRes', validationRes);
@@ -46,7 +156,8 @@
});
}
window.location.href = '/';
const redirect = redirectUrl || '/';
window.location.href = redirect.startsWith('/') ? redirect : `/?redirect=${encodeURIComponent(redirect)}`;
}
}
);
@@ -65,7 +176,7 @@
<title>Welcome back to {$currentOrg.name || 'ClassroomIO'}</title>
</svelte:head>
<AuthUI isLogin={true} {handleSubmit} isLoading={loading}>
<AuthUI isLogin={true} {handleSubmit} isLoading={loading} {hideGoogleAuth}>
<div class="ui:flex ui:flex-col ui:gap-6">
<Field.Field>
<Field.Label for="email">{$t('login.email')}</Field.Label>
@@ -74,6 +185,7 @@
id="email"
type="email"
bind:value={fields.email}
oninput={handleEmailChange}
placeholder="[email protected]"
disabled={loading}
autofocus
@@ -86,34 +198,83 @@
</Field.Content>
</Field.Field>
<Field.Field>
<div class="ui:flex ui:items-center ui:justify-between">
<Field.Label for="password">{$t('login.password')}</Field.Label>
<a class="ui:text-sm ui:text-primary ui:hover:underline" href="/forgot">
{$t('login.forgot')}
</a>
<!-- SSO Section (when SSO is available) -->
{#if ssoState.available}
<div
class="ui:p-4 ui:bg-blue-50 ui:dark:bg-blue-900/20 ui:rounded-lg ui:border ui:border-blue-200 ui:dark:border-blue-800"
>
<div class="ui:flex ui:items-center ui:gap-3">
<ShieldIcon class="ui:size-5 ui:text-blue-600" />
<div class="ui:flex-1">
<p class="ui:font-medium ui:text-sm">
{$t('settings.auth.login.sso_enabled', { provider: ssoState.providerName || 'SSO' })}
</p>
{#if ssoState.required}
<p class="ui:text-xs ui:text-blue-600 ui:mt-1">
{$t('settings.auth.login.sso_required')}
</p>
{/if}
</div>
</div>
<Button type="button" variant="default" class="ui:w-full ui:mt-3" onclick={handleSsoLogin}>
{$t('settings.auth.login.sign_in_with_sso', { provider: ssoState.providerName || 'SSO' })}
</Button>
</div>
<Field.Content>
<Password
id="password"
bind:value={fields.password}
placeholder="************"
disabled={loading}
aria-invalid={errors.password ? 'true' : undefined}
autocomplete="current-password"
/>
{#if errors.password}
<Field.Error>{$t(errors.password)}</Field.Error>
{/if}
</Field.Content>
</Field.Field>
{#if submitError}
<p class="ui:text-sm ui:text-destructive">{submitError}</p>
{/if}
<Button type="submit" disabled={loading} {loading} class="ui:w-full">
{$t('login.login')}
</Button>
<!-- Divider when both SSO and password login are available -->
{#if ssoState.available && !ssoState.required && !($globalStore.isOrgSite && $currentOrg.disableEmailPassword)}
<div class="ui:relative ui:my-4">
<div class="ui:absolute ui:inset-0 ui:flex ui:items-center" aria-hidden="true">
<div class="ui:w-full ui:border-t ui:border-border"></div>
</div>
<div class="ui:relative ui:flex ui:justify-center ui:text-xs">
<span class="ui:bg-card ui:px-2 ui:text-muted-foreground">
{$t('settings.auth.login.or_continue_with')}
</span>
</div>
</div>
{/if}
<!-- Password Section (when not force SSO and email/password not disabled) -->
{#if !ssoState.required && !($globalStore.isOrgSite && $currentOrg.disableEmailPassword)}
<Field.Field>
<div class="ui:flex ui:items-center ui:justify-between">
<Field.Label for="password">{$t('login.password')}</Field.Label>
<a class="ui:text-sm ui:text-primary ui:hover:underline" href="/forgot">
{$t('login.forgot')}
</a>
</div>
<Field.Content>
<Password
id="password"
bind:value={fields.password}
placeholder="************"
disabled={loading}
aria-invalid={errors.password ? 'true' : undefined}
autocomplete="current-password"
/>
{#if errors.password}
<Field.Error>{$t(errors.password)}</Field.Error>
{/if}
</Field.Content>
</Field.Field>
{#if submitError}
<p class="ui:text-sm ui:text-destructive">{submitError}</p>
{/if}
<Button type="submit" disabled={loading} {loading} class="ui:w-full">
{$t('login.login')}
</Button>
{:else if $globalStore.isOrgSite && $currentOrg.disableEmailPassword && !ssoState.available}
<div
class="ui:p-4 ui:bg-amber-50 ui:dark:bg-amber-900/20 ui:rounded-lg ui:border ui:border-amber-200 ui:dark:border-amber-800"
>
<p class="ui:text-sm ui:text-amber-800 ui:dark:text-amber-200">
{$t('settings.auth.login.email_password_disabled_message')}
</p>
</div>
{/if}
</div>
</AuthUI>
@@ -177,7 +177,7 @@
<!-- Footer -->
<div class="mt-8 flex w-full items-center justify-between">
<div class="relative h-2 w-24 bg-gray-300">
<span class="progress bg-primary-700 absolute left-0 top-0 h-full" style="width: {progress}%;"></span>
<span class="progress bg-primary-700 absolute top-0 left-0 h-full" style="width: {progress}%;"></span>
</div>
<div class="flex">
@@ -19,7 +19,7 @@
token: ''
});
const isSubmitDisabled = $derived!!(
const isSubmitDisabled = $derived!(
fields.password && fields.confirmPassword && fields.password !== fields.confirmPassword
);
const token = $derived(new URLSearchParams(page.url.search).get('token'));
@@ -4,13 +4,18 @@
import { SIGNUP_FIELDS } from '$lib/utils/constants/authentication';
import { t } from '$lib/utils/functions/translations';
import { authValidation, getConfirmPasswordError, getDisableSubmit } from '$lib/utils/functions/validator';
import { page } from '$app/state';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { globalStore } from '$lib/utils/store/app';
import { currentOrg } from '$lib/utils/store/org';
import { authClient } from '$lib/utils/services/auth/client';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { Password } from '@cio/ui/custom/password';
import { Button } from '@cio/ui/base/button';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
let fields = $state(Object.assign({}, SIGNUP_FIELDS));
let loading = $state(false);
@@ -22,8 +27,35 @@
let submitError: string = $state('');
const disableSubmit = $derived(getDisableSubmit(fields));
const redirectUrl = $derived(page.url.searchParams.get('redirect'));
const inviteToken = $derived(page.url.searchParams.get('invite_token'));
// Invite context: allow signup when invite_token present or redirect contains invite info
const hasInviteContext = $derived(
!!inviteToken || (!!redirectUrl && (redirectUrl.includes('/invite/t/') || redirectUrl.includes('invite_token')))
);
const inviteOnly = $derived(!!$currentOrg?.settings?.signup?.inviteOnly);
const signupRestricted = $derived(
$globalStore.isOrgSite && ($currentOrg.disableSignup || (inviteOnly && !hasInviteContext))
);
// Hide Google Auth if disabled for org
const hideGoogleAuth = $derived(!!($globalStore.isOrgSite && $currentOrg.disableGoogleAuth));
// Check if signup is disabled
onMount(() => {
if ($globalStore.isOrgSite && $currentOrg.disableSignup) {
goto(resolve('/login?error=signup_disabled', {}));
}
});
async function handleSubmit() {
if (signupRestricted) {
submitError = t.get('settings.auth.login.signup_disabled_error');
return;
}
const validationRes = authValidation(fields);
console.log('validationRes', validationRes);
@@ -43,6 +75,9 @@
name: name
},
{
fetchOptions: {
headers: $globalStore.isOrgSite && $currentOrg?.id ? { 'cio-org-id': $currentOrg.id } : {}
},
onSuccess: (ctx) => {
console.log('Signup successful');
capturePosthogEvent('user_signed_up', {
@@ -59,14 +94,18 @@
});
}
window.location.href = '/';
const redirect = redirectUrl || '/';
window.location.href = redirect.startsWith('/') ? redirect : `/?redirect=${encodeURIComponent(redirect)}`;
}
}
);
if (error) throw error;
} catch (error: any) {
submitError = error?.error_description || error?.message;
} catch (error) {
submitError =
(error as { error_description?: string; message?: string })?.error_description ||
(error as { message?: string })?.message ||
'';
loading = false;
}
}
@@ -88,68 +127,87 @@
<SenjaEmbed id="aa054658-1e15-4d00-8920-91f424326c4e" />
<AuthUI isLogin={false} {handleSubmit} isLoading={loading}>
<div class="ui:flex ui:flex-col ui:gap-6">
<Field.Field>
<Field.Label for="email">{$t('login.fields.email')}</Field.Label>
<Field.Content>
<Input
id="email"
type="email"
bind:value={fields.email}
placeholder="[email protected]"
disabled={loading}
autofocus
aria-invalid={errors.email ? 'true' : undefined}
autocomplete="email"
/>
{#if errors.email}
<Field.Error>{$t(errors.email)}</Field.Error>
{/if}
</Field.Content>
</Field.Field>
<Field.Field>
<Field.Label for="password">{$t('login.fields.password')}</Field.Label>
<Field.Content>
<Password
id="password"
bind:value={fields.password}
placeholder="************"
disabled={loading}
aria-invalid={errors.password ? 'true' : undefined}
autocomplete="new-password"
/>
{#if errors.password}
<Field.Error>{$t(errors.password)}</Field.Error>
{/if}
<Field.Description>{$t('login.fields.password_helper_message')}</Field.Description>
</Field.Content>
</Field.Field>
<Field.Field>
<Field.Label for="confirmPassword">{$t('login.fields.confirm_password')}</Field.Label>
<Field.Content>
<Password
id="confirmPassword"
bind:value={fields.confirmPassword}
placeholder="************"
disabled={loading}
aria-invalid={errors.confirmPassword ? 'true' : undefined}
autocomplete="new-password"
/>
{#if errors.confirmPassword}
<Field.Error>{errors.confirmPassword}</Field.Error>
{/if}
</Field.Content>
</Field.Field>
{#if submitError}
<p class="ui:text-sm ui:text-destructive">{submitError}</p>
{/if}
<Button type="submit" disabled={disableSubmit || loading} {loading} class="ui:w-full">
{$t('login.create_account')}
{#if signupRestricted}
<div
class="ui:p-6 ui:bg-amber-50 ui:dark:bg-amber-900/20 ui:rounded-lg ui:border ui:border-amber-200 ui:dark:border-amber-800 ui:text-center"
>
<h2 class="ui:text-lg ui:font-semibold ui:text-amber-800 ui:dark:text-amber-200">
{$t('login.signup_disabled.title')}
</h2>
<p class="ui:mt-2 ui:text-sm ui:text-amber-700 ui:dark:text-amber-300">
{$currentOrg.disableSignupMessage ||
(inviteOnly
? $t('login.signup_disabled.invite_only_message')
: $t('settings.auth.login.signup_disabled_error'))}
</p>
<Button variant="outline" class="ui:mt-4" onclick={() => goto(resolve('/login', {}))}>
{$t('login.signup_disabled.go_to_login')}
</Button>
</div>
</AuthUI>
{:else}
<AuthUI isLogin={false} {handleSubmit} isLoading={loading} {hideGoogleAuth}>
<div class="ui:flex ui:flex-col ui:gap-6">
<Field.Field>
<Field.Label for="email">{$t('login.fields.email')}</Field.Label>
<Field.Content>
<Input
id="email"
type="email"
bind:value={fields.email}
placeholder="[email protected]"
disabled={loading}
autofocus
aria-invalid={errors.email ? 'true' : undefined}
autocomplete="email"
/>
{#if errors.email}
<Field.Error>{$t(errors.email)}</Field.Error>
{/if}
</Field.Content>
</Field.Field>
<Field.Field>
<Field.Label for="password">{$t('login.fields.password')}</Field.Label>
<Field.Content>
<Password
id="password"
bind:value={fields.password}
placeholder="************"
disabled={loading}
aria-invalid={errors.password ? 'true' : undefined}
autocomplete="new-password"
/>
{#if errors.password}
<Field.Error>{$t(errors.password)}</Field.Error>
{/if}
<Field.Description>{$t('login.fields.password_helper_message')}</Field.Description>
</Field.Content>
</Field.Field>
<Field.Field>
<Field.Label for="confirmPassword">{$t('login.fields.confirm_password')}</Field.Label>
<Field.Content>
<Password
id="confirmPassword"
bind:value={fields.confirmPassword}
placeholder="************"
disabled={loading}
aria-invalid={errors.confirmPassword ? 'true' : undefined}
autocomplete="new-password"
/>
{#if errors.confirmPassword}
<Field.Error>{errors.confirmPassword}</Field.Error>
{/if}
</Field.Content>
</Field.Field>
{#if submitError}
<p class="ui:text-sm ui:text-destructive">{submitError}</p>
{/if}
<Button type="submit" disabled={disableSubmit || loading} {loading} class="ui:w-full">
{$t('login.create_account')}
</Button>
</div>
</AuthUI>
{/if}
@@ -58,7 +58,7 @@
variant="page"
layout="full-page"
showLogo={true}
class="h-fit! flex-none! max-h-none!"
class="h-fit! max-h-none! flex-none!"
>
{#if type === 'legacy_method_blocked'}
<div class="rounded-md border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-900/20">
+18 -32
View File
@@ -2,46 +2,32 @@
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { page } from '$app/state';
import { globalStore } from '$lib/utils/store/app';
import { ErrorIcon } from '$features/ui/icons';
import { Button } from '@cio/ui/base/button';
import { Empty } from '@cio/ui/custom/empty';
import HeartCrack from '@lucide/svelte/icons/heart-crack';
if (page.status == 404 && browser) {
goto('/404');
}
console.error(page.error?.message);
// TODO: Add error tracking here
console.error('error message', page.error?.message);
</script>
<svelte:head>
<title>{page.status}</title>
<title>Something unexpected occured</title>
</svelte:head>
<div
class="error-container m-auto flex h-full flex-col-reverse items-center justify-center py-10 md:flex-col-reverse lg:flex-row dark:bg-black"
<Empty
title="Something unexpected occured."
description="Don't worry, your learning is safe. It isn't your fault, it is ours. We have gotten the error notification and will push a fix ASAP. In the meantime, take a short break and come back a bit later."
icon={HeartCrack}
variant="page"
layout="full-page"
showLogo={true}
>
<div class="p-2 md:w-full lg:w-2/4">
<h2 class="w-full text-lg font-medium lg:text-3xl lg:font-normal">Something unexpected occured.</h2>
<p class="my-6 w-full text-base font-normal text-gray-600 dark:text-white">
Don't worry, your learning is safe. It isn't your fault, it is ours. We have gotten the error notification and
will push a fix ASAP. In the meantime, take a short break and come back a bit later.
</p>
<a
class="bg-primary-700 hover:bg-primary-900 mt-3 rounded-xl px-9 py-3 text-white transition-all hover:no-underline"
href="/">Go Home</a
>
<div class="flex gap-2">
<Button href="https://classroomio.com/tools" variant="secondary">Try Free tools</Button>
<Button href="/">Go Home</Button>
</div>
<div class="flex w-72 items-center justify-center lg:w-2/4">
<!-- to check if it's in dark mode so as to render the appropriate svg -->
{#if $globalStore.isDark}
<ErrorIcon size="{16}DarkMode" />
{:else}
<ErrorIcon size={16} />
{/if}
</div>
</div>
<style>
.error-container {
max-width: 964px;
}
</style>
</Empty>
+14 -54
View File
@@ -1,21 +1,11 @@
import { getSupabase, supabase } from '$lib/utils/functions/supabase';
import type { AccountOrg } from '$features/app/types';
import type { MetaTagsProps } from 'svelte-meta-tags';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
import { dev } from '$app/environment';
import { env } from '$env/dynamic/private';
import { getOrgSiteInfo } from '$features/app/layout-setup';
import { redirect } from '@sveltejs/kit';
if (!supabase) {
getSupabase();
}
import { getBaseMetaTags } from '$lib/utils/functions/metaTags';
export const ssr = PUBLIC_IS_SELFHOSTED === 'true' ? false : true;
const APP_SUBDOMAINS = env.PRIVATE_APP_SUBDOMAINS?.split(',') || [];
interface LoadOutput {
orgSiteName: string;
isOrgSite: boolean;
@@ -23,68 +13,38 @@ interface LoadOutput {
org: AccountOrg | null;
baseMetaTags: MetaTagsProps;
serverLang: string;
localeCookie: string;
locals: App.Locals;
}
export const load = async ({ url, cookies, request, locals }): Promise<LoadOutput> => {
const loadStart = performance.now();
const debugPlay = cookies.get('debugPlay');
const orgSiteInfoStart = performance.now();
const orgSiteInfo = await getOrgSiteInfo(url, cookies);
const orgSiteInfoMs = Math.round((performance.now() - orgSiteInfoStart) * 100) / 100;
const response: LoadOutput = {
orgSiteName: orgSiteInfo.orgSiteName,
isOrgSite: orgSiteInfo.isOrgSite,
skipAuth: orgSiteInfo.subdomain === 'play' || debugPlay === 'true',
org: orgSiteInfo.org,
baseMetaTags: getBaseMetaTags(url),
baseMetaTags: getBaseMetaTags(url, orgSiteInfo),
serverLang: request.headers?.get('accept-language') || '',
localeCookie: cookies.get('classroomio_locale') || '',
locals
};
console.log('PUBLIC_IS_SELFHOSTED', PUBLIC_IS_SELFHOSTED);
const loadMs = Math.round((performance.now() - loadStart) * 100) / 100;
console.log(
`[+layout.server] load: ${loadMs}ms (getOrgSiteInfo: ${orgSiteInfoMs}ms) | PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED}`
);
// If it isn't a registered dashboard domain and also not a valid sub domain.
if (!APP_SUBDOMAINS.includes(orgSiteInfo.subdomain) && !dev && !orgSiteInfo.isOrgSite) {
redirect(307, 'https://app.classroomio.com');
}
// if (!APP_SUBDOMAINS.includes(orgSiteInfo.subdomain) && !dev && !orgSiteInfo.isOrgSite) {
// redirect(307, 'https://app.classroomio.com');
// }
return response;
};
function getBaseMetaTags(url: URL) {
return Object.freeze({
title: 'ClassroomIO | The Open Source Learning Management System for Companies',
description:
'A flexible, user-friendly platform for creating, managing, and delivering courses for companies and training organisations',
canonical: new URL(url.pathname, url.origin).href,
openGraph: {
type: 'website',
url: new URL(url.pathname, url.origin).href,
locale: 'en_IE',
title: 'ClassroomIO | The Open Source Learning Management System for Companies',
description:
'A flexible, user-friendly platform for creating, managing, and delivering courses for companies and training organisations',
siteName: 'ClassroomIO',
images: [
{
url: 'https://brand.cdn.clsrio.com/og/classroomio-og.png',
alt: 'ClassroomIO OG Image',
width: 1920,
height: 1080,
secureUrl: 'https://brand.cdn.clsrio.com/og/classroomio-og.png',
type: 'image/jpeg'
}
]
},
twitter: {
handle: '@classroomio',
site: '@classroomio',
cardType: 'summary_large_image' as const,
title: 'ClassroomIO | The Open Source Learning Management System for Companies',
description:
'A flexible, user-friendly platform for creating, managing, and delivering courses for companies and training organisations',
image: 'https://brand.cdn.clsrio.com/og/classroomio-og.png',
imageAlt: 'ClassroomIO OG Image'
}
});
}
+39 -33
View File
@@ -5,11 +5,11 @@
import { Snackbar } from '$features/ui';
import { UpgradeModal, PageLoadProgress, PageRestricted } from '$features/ui';
import { user } from '$lib/utils/store/user';
import { setupAnalytics } from '$lib/utils/functions/appSetup';
import { setTheme } from '$lib/utils/functions/theme';
import { initOrgAnalytics } from '$lib/utils/services/posthog';
import { setupCloudAnalytics } from '$lib/utils/functions/appSetup';
import { globalStore } from '$lib/utils/store/app';
import { currentOrg } from '$lib/utils/store/org';
import { currentOrg, isOrgStudent } from '$lib/utils/store/org';
import { appInitApi } from '$features/app/init.svelte';
import merge from 'lodash/merge';
import { onMount } from 'svelte';
@@ -33,7 +33,7 @@
data
);
setupAnalytics();
setupCloudAnalytics();
if (data.locals.user) {
user.set({
@@ -48,15 +48,20 @@
// Authentication Steps
if (!data.isOrgSite || !data.org) return;
$globalStore.orgSiteName = data.orgSiteName;
$globalStore.orgSiteName = data.orgSiteName || '';
$globalStore.isOrgSite = data.isOrgSite;
currentOrg.set(data.org);
// Setup internal analytics
initOrgAnalytics(data.orgSiteName);
if (data.orgSiteName) {
initOrgAnalytics(data.orgSiteName);
}
setTheme(data.org?.theme);
const theme = data.org?.theme;
if (theme) {
setTheme(theme);
}
}
onMount(() => {
@@ -75,12 +80,11 @@
console.log('session is pending or refetching');
return;
}
console.log('path', path);
console.log('isPublicRoute', isPublicRoute(path));
console.log('data', $session.data);
// No need to require login for public routes
if (isPublicRoute(path) && path !== '/') return;
if (isPublicRoute(path) && (path !== '/' || data.isOrgSite)) {
return;
}
if (!$session.data && path !== '/login') {
console.log('session data is not available, go to login');
@@ -109,28 +113,39 @@
}
});
$effect(() => {
// Means it hasn't been set yet.
if ($isOrgStudent === null) return;
if ($isOrgStudent !== $globalStore.isStudent) {
globalStore.update((s) => ({ ...s, isStudent: !!$isOrgStudent }));
}
});
const metaTags = $derived(merge(data.baseMetaTags, page.data.pageMetaTags));
</script>
<ModeWatcher />
<div>
<ModeWatcher />
<MetaTags {...metaTags} />
<MetaTags {...metaTags} />
<UpgradeModal />
<UpgradeModal />
<Snackbar />
<Snackbar />
{#if data.org?.isRestricted || $currentOrg.isRestricted}
<PageRestricted />
{:else if data.skipAuth}
<PlayQuiz />
{:else if data.isOrgSite && data.org && path === '/'}
<OrgLandingPage orgSiteName={data.orgSiteName} org={data.org} />
{:else}
<PageLoadProgress zIndex={10000} />
{#if data.org?.isRestricted || $currentOrg.isRestricted}
<PageRestricted />
{:else if data.skipAuth}
<PlayQuiz />
{:else if data.isOrgSite && data.org && path === '/'}
<OrgLandingPage orgSiteName={data.orgSiteName} org={data.org} />
{:else}
<PageLoadProgress zIndex={10000} />
{@render children?.()}
{/if}
{@render children?.()}
{/if}
</div>
<style>
:global(:root) {
@@ -150,15 +165,6 @@
border-bottom: 1px solid var(--border-color);
}
:global(.plyr__controls) {
background:
url(/logo-192.png) 99% 70% no-repeat,
linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.5)) !important;
background-size:
50px auto,
auto !important;
}
:global(.cards-container) {
width: 90%;
margin: 0 auto;
+4 -3
View File
@@ -1,13 +1,14 @@
import { config, loadTranslations } from '$lib/utils/functions/translations';
import { config, getPersistedLocale, loadTranslations } from '$lib/utils/functions/translations';
const SUPPORTED_LANGUAGES = config?.loaders?.map((loader) => loader.locale) || [];
export const load = async ({ url, data }) => {
const { pathname } = url;
const serverLang = data.serverLang.split('-')[0];
const serverLang = data.serverLang?.split?.('-')?.[0] || 'en';
const persistedLocale = data.localeCookie || getPersistedLocale();
const userLocale = data.locals?.profile?.locale || getInitialLocale(serverLang);
const userLocale = persistedLocale || data.locals?.profile?.locale || getInitialLocale(serverLang);
const initLocale = getInitialLocale(userLocale);
await loadTranslations(initLocale, pathname); // keep this just before the `return`
+1 -7
View File
@@ -1,20 +1,14 @@
<script>
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { Button } from '@cio/ui/base/button';
import SearchXIcon from '@lucide/svelte/icons/search-x';
import { Empty } from '@cio/ui/custom/empty';
import { resolve } from '$app/paths';
let query = $derived(new URLSearchParams(page.url.search));
let isOrg = $derived(query.get('type') === 'org');
function handleClick() {
if (!isOrg) {
return goto(resolve('/', {}));
}
window.location.href = 'https://classroomio.com';
window.location.href = isOrg ? 'https://classroomio.com' : '/';
}
</script>
@@ -1,64 +0,0 @@
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
export async function POST({ request }) {
const authHeader = request.headers.get('authorization');
const cronSecret = process.env.CRON_SECRET;
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
return json(
{
success: false,
error: 'Unauthorized'
},
{ status: 401 }
);
}
const supabase = getServerSupabase();
try {
// Clean up expired tokens
const { data: cleanupResult, error } = await supabase.rpc('cleanup_expired_verification_tokens');
if (error) {
console.error('Token cleanup failed:', error);
return json(
{
success: false,
error: error.message
},
{ status: 500 }
);
}
console.log('Token cleanup completed:', {
deletedTokens: cleanupResult,
timestamp: new Date().toISOString()
});
return json({
success: true,
deletedTokens: cleanupResult,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Unexpected error during cleanup:', error);
return json(
{
success: false,
error: 'Unexpected error'
},
{ status: 500 }
);
}
}
// Health check endpoint
export async function GET() {
return json({
service: 'email-verification-cleanup',
status: 'active',
timestamp: new Date().toISOString()
});
}
@@ -1,81 +0,0 @@
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
export async function GET({ url, request }) {
// Simple auth check - in production this should be more robust
const authHeader = request.headers.get('authorization');
const adminSecret = process.env.ADMIN_SECRET;
if (!adminSecret || authHeader !== `Bearer ${adminSecret}`) {
return json(
{
success: false,
error: 'Unauthorized'
},
{ status: 401 }
);
}
const supabase = getServerSupabase();
try {
// Get verification token statistics
const { data: tokenStats, error: tokenError } = await supabase
.from('email_verification_tokens')
.select('id, created_at, expires_at, used_at, created_by_ip, used_by_ip, email')
.order('created_at', { ascending: false })
.limit(100);
if (tokenError) {
console.error('Failed to fetch token stats:', tokenError);
return json(
{
success: false,
error: 'Database error'
},
{ status: 500 }
);
}
// Calculate stats
const now = new Date();
const last24Hours = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const recent = tokenStats.filter((token) => new Date(token.created_at) > last24Hours);
const used = tokenStats.filter((token) => token.used_at);
const expired = tokenStats.filter((token) => new Date(token.expires_at) < now && !token.used_at);
// Count unique IPs for anomaly detection
const uniqueCreationIPs = new Set(tokenStats.map((t) => t.created_by_ip)).size;
const uniqueUsageIPs = new Set(tokenStats.filter((t) => t.used_by_ip).map((t) => t.used_by_ip)).size;
return json({
success: true,
stats: {
total_tokens: tokenStats.length,
recent_24h: recent.length,
used_tokens: used.length,
expired_unused: expired.length,
unique_creation_ips: uniqueCreationIPs,
unique_usage_ips: uniqueUsageIPs,
success_rate: tokenStats.length > 0 ? ((used.length / tokenStats.length) * 100).toFixed(1) : 0
},
recent_activity: recent.slice(0, 10).map((token) => ({
created_at: token.created_at,
email: token.email.replace(/(.{2}).*@/, '$1***@'), // Mask email for privacy
used: !!token.used_at,
expired: new Date(token.expires_at) < now,
ip: token.created_by_ip
}))
});
} catch (error) {
console.error('Security monitoring error:', error);
return json(
{
success: false,
error: 'Unexpected error'
},
{ status: 500 }
);
}
}
@@ -1,89 +0,0 @@
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import type { OrganisationAnalytics } from '$lib/utils/types/analytics';
import { json } from '@sveltejs/kit';
const supabase = getServerSupabase();
const CACHE_DURATION = 60 * 5; // 5 minutes
export async function POST({ setHeaders, request }) {
const { orgId } = await request.json();
if (!orgId) {
return json({ success: false, message: 'Request is missing required fields' }, { status: 400 });
}
setHeaders({
'cache-control': `max-age=${CACHE_DURATION}`,
'content-type': 'application/json'
});
try {
const analytics = await getOrganisationAnalytics(orgId);
return json(analytics, { status: 200 });
} catch (error) {
return json({ error: 'Something went wrong' }, { status: 500 });
}
}
async function getOrganisationAnalytics(orgId: string): Promise<OrganisationAnalytics> {
const analytics: OrganisationAnalytics = {
revenue: 0,
numberOfCourses: 0,
totalStudents: 0,
topCourses: [],
enrollments: []
};
// Run all queries in parallel
const [statsResult, topCoursesResult, enrollmentsResult] = await Promise.all([
supabase.from('dash_org_stats').select('*').eq('org_id', orgId),
supabase
.rpc('get_dash_org_top_courses', {
org_id_arg: orgId
})
.order('completion_percentage', { ascending: false }),
supabase.rpc('get_dash_org_recent_enrollments', {
org_id_arg: orgId
})
]);
// Check for errors
if (statsResult.error) {
console.error(statsResult.error);
throw new Error('Failed to fetch organisation analytics');
}
if (topCoursesResult.error) {
console.error(topCoursesResult.error);
throw new Error('Failed to fetch top courses');
}
if (enrollmentsResult.error) {
console.error(enrollmentsResult.error);
throw new Error('Failed to fetch enrollments');
}
// Set analytics data
analytics.revenue = 0; // Hardcode until we implement payment
analytics.numberOfCourses = statsResult.data?.[0]?.no_of_courses;
analytics.totalStudents = statsResult.data?.[0]?.enrolled_students;
analytics.topCourses = topCoursesResult.data.map((course) => ({
id: course.course_id,
title: course.course_title,
enrollments: course.total_students,
completion: course.completion_percentage
}));
analytics.enrollments = enrollmentsResult.data.map((enrollment) => ({
id: enrollment.profile_id,
avatarUrl: enrollment.avatar_url,
name: enrollment.fullname,
courseId: enrollment.course_id,
course: enrollment.course_title,
date: enrollment.enrolled_at
}));
return analytics;
}
@@ -1,260 +0,0 @@
import type {
UserAnalytics,
UserCourseAnalytics,
UserExerciseStatsQuery,
UserExercisesStats
} from '$lib/utils/types/analytics';
import { fetchCourses, fetchProfileCourseProgress } from '$lib/utils/services/courses';
import { calcPercentageWithRounding } from '$lib/utils/functions/number';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { json } from '@sveltejs/kit';
const supabase = getServerSupabase();
const CACHE_DURATION = 60 * 5; // 5 minutes
export async function POST({ setHeaders, request }) {
const { userId, courseId, orgId } = await request.json();
if (!userId) {
return json({ success: false, message: 'Request is missing required fields' }, { status: 400 });
}
setHeaders({
'cache-control': `max-age=${CACHE_DURATION}`,
'content-type': 'application/json'
});
if (courseId) {
const userCourseAnalytics = await getStudentAnalyticsData(userId, courseId);
return json(userCourseAnalytics);
}
if (orgId) {
const userAnalytics = await getAudienceData(userId, orgId);
return json(userAnalytics);
}
return json({ error: 'Invalid request' }, { status: 400 });
}
function sumArrObject<T>(arr: T[], key: keyof T) {
return arr.reduce((sum, item) => sum + ((item[key] as number) || 0), 0);
}
async function getLastLogin(userId: string): Promise<string | undefined> {
try {
const { data, error } = await supabase
.from('analytics_login_events')
.select('logged_in_at')
.eq('user_id', userId)
.single();
if (error) throw error;
return data?.logged_in_at;
} catch (error) {
console.error(error);
}
}
async function getAudienceData(userId: string, orgId: string): Promise<UserAnalytics> {
const audienceAnalytics: UserAnalytics = {
user: {
id: userId,
fullName: 'John Doe',
email: '[email protected]',
avatarUrl: 'https://example.com/avatar.png',
lastSeen: new Date().toISOString()
},
courses: [],
overallCourseProgress: 0,
overallAverageGrade: 0
};
const userResult = await supabase.from('profile').select('fullname, email, avatar_url').eq('id', userId).single();
if (userResult.error) throw new Error('Failed to fetch user profile');
audienceAnalytics.user.fullName = userResult.data.fullname;
audienceAnalytics.user.email = userResult.data.email;
audienceAnalytics.user.avatarUrl = userResult.data.avatar_url || '';
audienceAnalytics.user.lastSeen = await getLastLogin(userId);
const { allCourses = [] } = (await fetchCourses(userId, orgId)) || {};
for (const course of allCourses) {
const [userExercisesStats, userCourseProgress] = await Promise.all([
fetchUserExercisesStats(course.id, userId),
fetchProfileCourseProgress(course.id, userId)
]);
const courseProgress = userCourseProgress?.data?.[0];
const exercisesStats = userExercisesStats || [];
const totalEarnedPoints = sumArrObject(exercisesStats, 'score');
const totalPoints = sumArrObject(exercisesStats, 'totalPoints');
const averageGrade = calcPercentageWithRounding(totalEarnedPoints, totalPoints);
const lessonsCompleted = courseProgress?.lessons_completed || 0;
const lessonsCount = courseProgress?.lessons_count || 0;
audienceAnalytics.courses.push({
...course,
...courseProgress,
progress_percentage: calcPercentageWithRounding(lessonsCompleted, lessonsCount),
average_grade: averageGrade
});
}
const totalLessons = audienceAnalytics.courses.reduce((acc, course) => acc + course.lessons_count, 0);
const completedLessons = audienceAnalytics.courses.reduce((acc, course) => acc + course.lessons_completed, 0);
const overallCourseProgress = calcPercentageWithRounding(completedLessons, totalLessons);
const allGrades = sumArrObject(audienceAnalytics.courses, 'average_grade');
const overallAverageGrade = calcPercentageWithRounding(allGrades, audienceAnalytics.courses.length);
audienceAnalytics.overallCourseProgress = overallCourseProgress;
audienceAnalytics.overallAverageGrade = overallAverageGrade || 0;
return audienceAnalytics;
}
async function getStudentAnalyticsData(userId: string, courseId: string): Promise<UserCourseAnalytics> {
const userCourseAnalytics: UserCourseAnalytics = {
user: {
id: userId,
fullName: '',
avatarUrl: '',
lastSeen: '',
email: ''
},
averageGrade: 0,
userExercisesStats: [],
totalExercises: 0,
completedExercises: 0,
progressPercentage: 0
};
// fetch user details
const { data: userResult, error: userError } = await supabase
.from('profile')
.select('fullname, email, avatar_url')
.eq('id', userId)
.single();
if (userError) throw new Error('Failed to fetch user profile' + userError.message);
userCourseAnalytics.user.fullName = userResult.fullname;
userCourseAnalytics.user.email = userResult.email;
userCourseAnalytics.user.avatarUrl = userResult.avatar_url || '';
userCourseAnalytics.user.lastSeen = await getLastLogin(userId);
// fetch marks, lessons, and exercise progress
const [userExercisesStats, lessons, exerciseResponse] = await Promise.all([
fetchUserExercisesStats(courseId, userId),
fetchLessonsWithCompletion(courseId, userId),
fetchProfileCourseProgress(courseId, userId)
]);
if (!userExercisesStats || !lessons || !exerciseResponse.data) {
throw new Error('Failed to fetch course analytics data');
}
const totalEarnedPoints = sumArrObject(userExercisesStats, 'score');
const totalPoints = sumArrObject(userExercisesStats, 'totalPoints');
userCourseAnalytics.averageGrade = calcPercentageWithRounding(totalEarnedPoints, totalPoints);
userCourseAnalytics.userExercisesStats = userExercisesStats;
const completedLessons = lessons.filter((lesson) => lesson.completed);
userCourseAnalytics.totalExercises = exerciseResponse.data[0].exercises_count;
userCourseAnalytics.completedExercises = exerciseResponse.data[0].exercises_completed;
console.log('completedLessons.length', completedLessons.length);
console.log('lessons.length', lessons.length);
userCourseAnalytics.progressPercentage = calcPercentageWithRounding(completedLessons.length, lessons.length);
return userCourseAnalytics;
}
async function fetchUserExercisesStats(courseId: string, userId: string): Promise<UserExercisesStats[] | undefined> {
try {
const { data: courseData, error: queryError } = await supabase
.from('course')
.select(
`
lesson!inner (
title,exercise!left (
id,title,lesson_id,created_at,question (points),
submission!left (
id,total,status_id,submitted_by,groupmember!inner (id,profile_id)
)
)
)
`
)
.eq('lesson.exercise.submission.groupmember.profile_id', userId)
.eq('id', courseId)
.returns<UserExerciseStatsQuery[]>();
if (queryError) {
console.error('Error fetching exercise data:', queryError);
return;
}
const userExercisesStats = courseData[0].lesson.flatMap((lesson) =>
lesson.exercise.map((exercise) => {
const totalPoints = exercise.question.reduce((sum, q) => sum + (q.points || 0), 0);
const userSubmission = exercise.submission[0];
return {
id: exercise.id,
lessonId: exercise.lesson_id,
lessonTitle: lesson.title,
title: exercise.title,
status: userSubmission?.status_id,
score: userSubmission?.total || 0,
totalPoints,
isCompleted: !!userSubmission
};
})
);
return userExercisesStats;
} catch (error) {
console.error('Unexpected error:', error);
}
}
async function fetchLessonsWithCompletion(courseId, userId) {
try {
const { data: lessons, error: lessonsError } = await supabase
.from('lesson')
.select(
`
id,title,created_at,
exercise:exercise(id),
lesson_completion!left (lesson_id)
`
)
.eq('course_id', courseId)
.eq('lesson_completion.profile_id', userId);
if (lessonsError) throw lessonsError;
return lessons.map((lesson) => ({
id: lesson.id,
title: lesson.title,
created_at: lesson.created_at,
completed: lesson.lesson_completion.length > 0,
exerciseNo: lesson.exercise.length
}));
} catch (error) {
console.error('Error fetching lessons or completions:', error);
return [];
}
}
@@ -1,47 +0,0 @@
import { Configuration, OpenAIApi } from 'openai-edge';
import { OpenAIStream, StreamingTextResponse } from 'ai';
import { env } from '$env/dynamic/private';
// Create an OpenAI API client (that's edge friendly!)
const openAIConfig = new Configuration({
apiKey: env.OPENAI_API_KEY
});
const openai = new OpenAIApi(openAIConfig);
// Set the runtime to edge for best performance
export const config = {
runtime: 'edge'
};
const instruction = {
outline: 'a structured lesson outline',
note: 'a sample lesson note',
activities: 'few interactive lesson activities to my lecture'
};
export async function POST({ request }) {
const { prompt } = await request.json();
const { courseTitle, lessonTitle, type, locale } = JSON.parse(prompt);
const response = await openai.createChatCompletion({
model: 'gpt-4o',
messages: [
{
role: 'system',
content: 'Act like an assistant of a lecturer'
},
{
role: 'user',
content: `Generate ${instruction[type]} given the course title is "${courseTitle}" and the title of the lesson is "${lessonTitle}". Format in HTML without any styling. MOST IMPORTANT DON'T include the title of the course and don't include the lesson title: "${lessonTitle}" in your output. Please make sure the content is well detailed and you output the content in this locale: ${locale}.
VERY IMPORTANT: DON'T INCLUDE THREE BACKTICKS AND html IN YOUR OUTPUT.
`
}
],
stream: true
});
// Convert the response into a friendly text-stream
const stream = OpenAIStream(response);
// Respond with the stream
return new StreamingTextResponse(stream);
}
@@ -1,38 +0,0 @@
import { Configuration, OpenAIApi } from 'openai-edge';
import { OpenAIStream, StreamingTextResponse } from 'ai';
import { env } from '$env/dynamic/private';
// Create an OpenAI API client (that's edge friendly!)
const openAIConfig = new Configuration({
apiKey: env.OPENAI_API_KEY
});
const openai = new OpenAIApi(openAIConfig);
// Set the runtime to edge for best performance
export const config = {
runtime: 'edge'
};
export async function POST({ request }) {
const { prompt } = await request.json();
const response = await openai.createChatCompletion({
model: 'gpt-4o',
messages: [
{
role: 'system',
content: 'Act like an assistant of a lecturer or an educator'
},
{
role: 'user',
content: `${prompt}. DONT ADD ANY CSS STYLE TO THE HTML FORMATTING AND DON'T USE CODE HTML TAG. ALSO DON'T INLUDE THREE BACKTICKS AND html`
}
],
stream: true
});
// Convert the response into a friendly text-stream
const stream = OpenAIStream(response);
// Respond with the stream
return new StreamingTextResponse(stream);
}
@@ -1,81 +0,0 @@
import { Configuration, OpenAIApi } from 'openai-edge';
import { OpenAIStream, StreamingTextResponse } from 'ai';
import { env } from '$env/dynamic/private';
// Create an OpenAI API client (that's edge friendly!)
const openAIConfig = new Configuration({
apiKey: env.OPENAI_API_KEY
});
const openai = new OpenAIApi(openAIConfig);
// Set the runtime to edge for best performance
export const config = {
runtime: 'edge'
};
export async function POST({ request }) {
const { prompt } = await request.json();
const { questionNumber, optionNumber, lessonNote } = JSON.parse(prompt);
const refinedPrompt = `
Please note this typescript interface:
interface Question_type {
id: number;
label: any;
}
const QuestionTypes: Question_type[] = [
{
id: 1,
label: 'RADIO'
},
{
id: 2,
label: 'CHECKBOX'
},
{
id: 3,
label: 'TEXTAREA'
}
];
interface ExerciseTemplate {
title: string;
description: string;
questionnaire: {
questions: {
title: string;
name: string;
points: number; // a number out of 10 to score the student if student gets the correct answer
order: number; // a unique number from 0 to n+1, that orders the question in ascending order
question_type: Question_type;
options: {
label: string;
is_correct: boolean;
};
}[]
};
}
I am a teacher and I need your help evaluating my students after I have taught a lesson. Please generate a test with ${questionNumber} questions and ${optionNumber} options. The sourse of this test should come from my lesson note in thripple quotes: """${lessonNote}""". Format the quiz to match the ExerciseTemplate interface above and VERY IMPORTANTANTLY: ONLY RETURN JSON FORMAT AND DO NOT EXPLAIN ANTHING.
NB: Ask a mixture of ${optionNumber} options and ${questionNumber} DIFFERENT Question Types(RADIO, CHECKBOX AND TEXTAREA), mark the correct answer.`;
const response = await openai.createChatCompletion({
model: 'gpt-4o',
messages: [
{
role: 'system',
content: 'You only spit out JSON and nothing else, dont give any explanation.'
},
{
role: 'user',
content: refinedPrompt
}
],
stream: true
});
// Convert the response into a friendly text-stream
const stream = OpenAIStream(response);
// Respond with the stream
return new StreamingTextResponse(stream);
}
@@ -1,50 +0,0 @@
import { env } from '$env/dynamic/private';
import { OpenAIStream, StreamingTextResponse } from 'ai';
import { Configuration, OpenAIApi } from 'openai-edge';
// Create an OpenAI API client (that's edge friendly!)
const openAIConfig = new Configuration({
apiKey: env.OPENAI_API_KEY
});
const openai = new OpenAIApi(openAIConfig);
// Set the runtime to edge for best performance
export const config = {
runtime: 'edge'
};
export async function POST({ request }) {
const { prompt } = await request.json();
const response = await openai.createChatCompletion({
model: 'gpt-4o',
messages: [
{
role: 'system',
content: 'You are a teaching assistant, you only spit out JSON and nothing else, dont give any explanation.'
},
{
role: 'user',
content: `
Given this array of objects below, there are 4 keys , id, question, answer and points. You are required to come up with a score between 0 and the point for each answer provided to the question and a simple explanation for giving that score, you are to return an array of object in json with the following keys; id which is the id provided for that object in the initial array, score which is the value you gave for that question and then your explanation in this format below:
{
id,
score,
explanation,
}
IMPORTANT: DO NOT GIVE ANY OTHER TEXT EXCEPT THE JSON
The array you need to grade is below:
${prompt}
`
}
],
stream: true
});
const stream = OpenAIStream(response);
return new StreamingTextResponse(stream);
}
@@ -1,288 +0,0 @@
import type { CourseAnalytics, StudentOverview } from '$lib/utils/types/analytics';
import type { RequestHandler } from './$types';
import type { UserExercisesStats } from '$lib/utils/types/analytics';
import { calcPercentageWithRounding } from '$lib/utils/functions/number.js';
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
import { fetchProfileCourseProgress } from '$lib/utils/services/courses';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { json } from '@sveltejs/kit';
const CACHE_DURATION = 60 * 5; // 5 minutes
export const GET: RequestHandler = async ({ setHeaders, request }) => {
const userId = request.headers.get('user_id');
if (!userId) {
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
}
const url = new URL(request.url);
const courseId = url.searchParams.get('courseId');
if (!courseId) {
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
}
try {
const supabase = getServerSupabase();
const hasPermission = await checkUserCoursePermissions(supabase, userId, courseId);
if (!hasPermission) {
return json(
{
success: false,
message: 'Access denied. User is not a member of this course or organization.'
},
{ status: 403 }
);
}
setHeaders({
'cache-control': `max-age=${CACHE_DURATION}`,
'content-type': 'application/json'
});
const analytics = await getCourseAnalytics(supabase, courseId);
return json({ success: true, data: analytics });
} catch (error) {
console.error('Course analytics error:', error);
return json({ success: false, message: 'Internal server error' }, { status: 500 });
}
};
async function getCourseAnalytics(supabase: any, courseId: string): Promise<CourseAnalytics> {
const analytics: CourseAnalytics = {
totalTutors: 0,
totalStudents: 0,
totalLessons: 0,
totalExercises: 0,
lessonCompletionRate: 0,
exerciseCompletionRate: 0,
averageGrade: 0,
students: []
};
// Fetch course basic info and group members
const { data: courseData, error: courseError } = await supabase
.from('course')
.select(
`
id,
title,
group(
members:groupmember(
id,
role_id,
profile_id,
profile(
id,
fullname,
email,
avatar_url
)
)
),
lessons:lesson(
id,
title,
exercise(id)
)
`
)
.eq('id', courseId)
.single();
if (courseError) {
throw new Error('Failed to fetch course data');
}
// Count tutors and students
const members = (courseData.group as any)?.members || [];
analytics.totalTutors = members.filter((member) => member.role_id === 1 || member.role_id === 2).length;
analytics.totalStudents = members.filter((member) => member.role_id === 3).length;
// Count lessons and exercises
analytics.totalLessons = courseData.lessons?.length || 0;
analytics.totalExercises =
courseData.lessons?.reduce((total, lesson) => total + (lesson.exercise?.length || 0), 0) || 0;
// Get student analytics
const studentAnalytics = await Promise.all(
members.filter((member) => member.role_id === 3).map((member) => getStudentOverview(supabase, courseId, member))
);
analytics.students = studentAnalytics.filter((student): student is StudentOverview => student !== null);
// Calculate aggregated metrics
if (analytics.students.length > 0) {
analytics.lessonCompletionRate = Math.round(
analytics.students.reduce((sum, student) => sum + student.progressPercentage, 0) / analytics.students.length
);
analytics.exerciseCompletionRate = Math.round(
analytics.students.reduce((sum, student) => {
const completionRate =
student.totalExercises > 0 ? (student.exercisesSubmitted / student.totalExercises) * 100 : 0;
return sum + completionRate;
}, 0) / analytics.students.length
);
analytics.averageGrade = Math.round(
analytics.students.reduce((sum, student) => sum + student.averageGrade, 0) / analytics.students.length
);
}
return analytics;
}
async function getLastLogin(supabase: any, userId: string): Promise<string | undefined> {
try {
const { data, error } = await supabase
.from('analytics_login_events')
.select('logged_in_at')
.eq('user_id', userId)
.order('logged_in_at', { ascending: false })
.limit(1);
if (error) throw error;
return data?.[0]?.logged_in_at;
} catch (error) {
console.error('Error fetching last login:', error);
return undefined;
}
}
async function getStudentOverview(supabase: any, courseId: string, member: any): Promise<StudentOverview | null> {
try {
// Use the same service function that the working analytics/user API uses
const { data: courseProgressData, error: progressError } = await fetchProfileCourseProgress(
courseId,
member.profile_id
);
if (progressError) {
console.error('Error fetching course progress:', progressError);
return null;
}
const courseProgress = courseProgressData?.[0];
if (!courseProgress) {
console.error('No course progress data found');
return null;
}
// Fetch exercise stats using the same method as the people page
const userExercisesStats = await fetchUserExercisesStats(supabase, courseId, member.profile_id);
// Calculate exercise completion using the same logic as people page
const completedExercises = userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length || 0;
const totalExercises = userExercisesStats?.length || 0;
// Get last login using the same approach as analytics/user API
const lastLoginDate = await getLastLogin(supabase, member.profile_id);
// Format last seen
let lastSeen = 'Never';
if (lastLoginDate) {
const lastLogin = new Date(lastLoginDate);
const now = new Date();
const diffInHours = Math.floor((now.getTime() - lastLogin.getTime()) / (1000 * 60 * 60));
if (diffInHours < 1) {
lastSeen = 'Just now';
} else if (diffInHours < 24) {
lastSeen = `${diffInHours} hours ago`;
} else {
const diffInDays = Math.floor(diffInHours / 24);
lastSeen = `${diffInDays} days ago`;
}
}
// Calculate progress percentage
const lessonsCompleted = courseProgress.lessons_completed || 0;
const totalLessons = courseProgress.lessons_count || 0;
const progressPercentage = calcPercentageWithRounding(lessonsCompleted, totalLessons);
// Calculate average grade using the same logic as the people page
const totalEarnedPoints = userExercisesStats?.reduce((sum, exercise) => sum + exercise.score, 0) || 0;
const totalPoints = userExercisesStats?.reduce((sum, exercise) => sum + exercise.totalPoints, 0) || 0;
const averageGrade = calcPercentageWithRounding(totalEarnedPoints, totalPoints);
return {
id: member.profile_id,
profile: {
fullname: member.profile?.fullname || 'Unknown',
email: member.profile?.email || '',
avatar_url: member.profile?.avatar_url || ''
},
lessonsCompleted,
totalLessons,
exercisesSubmitted: completedExercises,
totalExercises: totalExercises,
averageGrade,
lastSeen,
progressPercentage
};
} catch (error) {
console.error('Error getting student overview:', error);
return null;
}
}
async function fetchUserExercisesStats(
supabase: any,
courseId: string,
userId: string
): Promise<UserExercisesStats[] | undefined> {
try {
const { data: courseData, error: queryError } = await supabase
.from('course')
.select(
`
lesson!inner (
title,exercise!inner (
id,title,lesson_id,created_at,question (points),
submission!left (
id,total,status_id,submitted_by,groupmember!inner (id,profile_id)
)
)
)
`
)
.eq('lesson.exercise.submission.groupmember.profile_id', userId)
.eq('id', courseId);
if (queryError) {
console.error('Error fetching exercise data:', queryError);
return;
}
if (!courseData || courseData.length === 0) {
return;
}
const userExercisesStats = courseData[0].lesson.flatMap(
(lesson) =>
lesson.exercise?.map((exercise) => {
const totalPoints = exercise.question?.reduce((sum, q) => sum + (q.points || 0), 0) || 0;
const userSubmission = exercise.submission?.[0];
return {
id: exercise.id,
lessonId: exercise.lesson_id,
lessonTitle: lesson.title,
title: exercise.title,
status: userSubmission?.status_id,
score: userSubmission?.total || 0,
totalPoints,
isCompleted: !!userSubmission
};
}) || []
);
return userExercisesStats;
} catch (error) {
console.error('Unexpected error:', error);
}
}
@@ -1,114 +0,0 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { STATUS } from '$lib/utils/constants/course';
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
const COURSE_SELECT_QUERY = `
id, title, type, description, overview, logo, is_published, version, group_id,
slug, cost, currency, metadata, is_certificate_downloadable, certificate_theme,
lesson_section(id, title, order, created_at),
lessons:lesson(
id, title, public, lesson_at, is_unlocked, order, created_at, section_id,
note, videos, slide_url, call_url, totalExercises:exercise(count),
totalComments:lesson_comment(count), profile:teacher_id(id, avatar_url, fullname),
lesson_completion(id, profile_id, is_complete)
),
attendance:group_attendance(*),
polls:apps_poll(status)
`;
const GROUP_MEMBERS_SELECT = `
id,
members:groupmember(
id, role_id, profile_id, email, created_at, assigned_student_id, profile(*)
)
`;
async function getCourse(supabase: any, courseId: string) {
const { data: course, error } = await supabase
.from('course')
.select(COURSE_SELECT_QUERY)
.eq('id', courseId)
.eq('status', STATUS[STATUS.ACTIVE])
.single();
if (error || !course) {
throw new Error('Course not found');
}
return course;
}
async function getGroupMembers(supabase: any, groupId: string, isStudent: boolean, userMembership: any) {
if (isStudent) {
return {
id: groupId,
members: userMembership ? [userMembership] : []
};
}
const { data: group, error } = await supabase.from('group').select(GROUP_MEMBERS_SELECT).eq('id', groupId).single();
if (error) {
throw new Error('Error fetching group members');
}
return {
id: group.id,
members: group.members
};
}
export const POST: RequestHandler = async ({ request }) => {
const userId = request.headers.get('user_id');
if (!userId) {
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
}
const { courseId } = await request.json();
if (!courseId) {
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
}
try {
const supabase = getServerSupabase();
const course = await getCourse(supabase, courseId);
const { hasAccess, isStudent, userMembership } = await checkUserCoursePermissions(
supabase,
userId,
course.group_id
);
if (!hasAccess) {
return json(
{
success: false,
message: 'Access denied. User is not a member of this course.',
data: null
},
{ status: 403 }
);
}
const group = await getGroupMembers(supabase, course.group_id, isStudent, userMembership);
return json({
success: true,
data: { ...course, group }
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error';
const status = message === 'Course not found' ? 404 : 500;
return json(
{
success: false,
message,
data: null
},
{ status }
);
}
};
@@ -1,60 +0,0 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
export const POST: RequestHandler = async ({ request }) => {
const userId = request.headers.get('user_id');
const { courseId } = await request.json();
if (!courseId) {
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
}
if (!userId) {
return json({ success: false, message: 'User ID is required' }, { status: 401 });
}
try {
const supabase = getServerSupabase();
// Get course group_id for permission check
const { data: course } = await supabase.from('course').select('group_id').eq('id', courseId).single();
if (!course) {
return json({ success: false, message: 'Course not found' }, { status: 404 });
}
const { hasAccess } = await checkUserCoursePermissions(supabase, userId, course.group_id);
if (!hasAccess) {
return json(
{
success: false,
message: 'Access denied'
},
{ status: 403 }
);
}
const { data: exercises, error } = await supabase.rpc('get_exercises').eq('course_id', courseId);
if (error) {
throw new Error('Error fetching exercises');
}
return json({
success: true,
data: exercises
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error';
return json(
{
success: false,
message
},
{ status: 500 }
);
}
};
@@ -1,62 +0,0 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
export const POST: RequestHandler = async ({ request }) => {
const userId = request.headers.get('user_id');
const { courseId } = await request.json();
if (!courseId) {
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
}
if (!userId) {
return json({ success: false, message: 'User ID is required' }, { status: 401 });
}
try {
const supabase = getServerSupabase();
// Get course group_id for permission check
const { data: course } = await supabase.from('course').select('group_id').eq('id', courseId).single();
if (!course) {
return json({ success: false, message: 'Course not found' }, { status: 404 });
}
const { hasAccess } = await checkUserCoursePermissions(supabase, userId, course.group_id);
if (!hasAccess) {
return json(
{
success: false,
message: 'Access denied'
},
{ status: 403 }
);
}
const { data: marks, error } = await supabase.rpc('get_marks').eq('course_id', courseId);
if (error) {
console.log('error', error);
throw new Error('Error fetching marks');
}
return json({
success: true,
data: marks
});
} catch (error) {
console.log('error', error);
const message = error instanceof Error ? error.message : 'Internal server error';
return json(
{
success: false,
message
},
{ status: 500 }
);
}
};
@@ -1,80 +0,0 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
export const GET: RequestHandler = async ({ request, url }) => {
const courseId = url.searchParams.get('courseId');
const userId = request.headers.get('user_id');
if (!userId) {
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
}
if (!courseId) {
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
}
try {
const supabase = getServerSupabase();
const hasPermission = await checkUserCoursePermissions(supabase, userId, courseId);
if (!hasPermission) {
return json(
{
success: false,
message: 'Access denied. User is not a member of this course or organization.'
},
{ status: 403 }
);
}
// Fetch newsfeeds
const { data, error } = await supabase
.from('course_newsfeed')
.select(
`
id,
created_at,
content,
course_id,
author:groupmember(
profile(
id,
fullname,
avatar_url
)
),
reaction,
is_pinned,
comment:course_newsfeed_comment(
id,
created_at,
author:groupmember( profile(id, fullname, avatar_url) ),
content,
course_newsfeed_id)
`
)
.match({ course_id: courseId })
.order('created_at', { ascending: false });
if (error) {
throw new Error('Error fetching newsfeeds');
}
return json({
success: true,
data: data || []
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error';
return json(
{
success: false,
message
},
{ status: 500 }
);
}
};
@@ -1,92 +0,0 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
export const GET: RequestHandler = async ({ request, url }) => {
const exerciseId = url.searchParams.get('exerciseId');
const courseId = url.searchParams.get('courseId');
const submittedBy = url.searchParams.get('submittedBy');
const userId = request.headers.get('user_id');
if (!userId) {
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
}
if (!exerciseId) {
return json({ success: false, message: 'Exercise ID is required' }, { status: 400 });
}
try {
const supabase = getServerSupabase();
// If courseId is provided, check permissions
if (courseId) {
const hasPermission = await checkUserCoursePermissions(supabase, userId, courseId);
if (!hasPermission) {
return json(
{
success: false,
message: 'Access denied. User is not a member of this course or organization.'
},
{ status: 403 }
);
}
}
// Build query object
const query: {
exercise_id: string;
course_id?: string;
submitted_by?: string;
} = {
exercise_id: exerciseId
};
if (courseId) {
query.course_id = courseId;
}
if (submittedBy) {
query.submitted_by = submittedBy;
}
// Fetch submission
const { data, error } = await supabase
.from('submission')
.select(
`
id,
answers:question_answer(*),
status_id,
feedback,
submitted_by:groupmember!inner(
profile!inner(
id,
fullname,
avatar_url
)
)
`
)
.match(query);
if (error) {
throw new Error('Error fetching submission');
}
return json({
success: true,
data: data || []
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error';
return json(
{
success: false,
message
},
{ status: 500 }
);
}
};
@@ -1,80 +0,0 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
export const GET: RequestHandler = async ({ request, url }) => {
const courseId = url.searchParams.get('courseId');
const userId = request.headers.get('user_id');
if (!userId) {
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
}
if (!courseId) {
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
}
try {
const supabase = getServerSupabase();
const hasPermission = await checkUserCoursePermissions(supabase, userId, courseId);
if (!hasPermission) {
return json(
{
success: false,
message: 'Access denied. User is not a member of this course or organization.'
},
{ status: 403 }
);
}
// Fetch submissions
const { data, error } = await supabase
.from('submission')
.select(
`
id,
created_at,
answers:question_answer(*),
exercise:exercise_id(
id, title, due_by,
lesson:lesson_id(id, title),
questions:question(
*,
options:option(*),
question_type:question_type_id(id, label)
)
),
status_id,
feedback,
course:course_id(*),
groupmember:submitted_by(
profile(*)
)
`
)
.match({
course_id: courseId
});
if (error) {
throw new Error('Error fetching submissions');
}
return json({
success: true,
data: data || []
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error';
return json(
{
success: false,
message
},
{ status: 500 }
);
}
};
@@ -1,39 +0,0 @@
import { addDomainToVercel, getConfigResponse, removeDomainFromVercelProject } from '$lib/utils/services/org/domain';
import { json } from '@sveltejs/kit';
export async function POST({ request }) {
const { params } = await request.json();
console.log('/GET api/domain', params);
if (!params?.key) {
return json({ success: false, message: 'Missing fields' }, { status: 400 });
}
if (params?.domain?.includes('classroomio.com')) {
return json({ success: false, message: 'Domain cannot contain classroomio.com' }, { status: 400 });
}
try {
switch (params.key) {
case 'verify_domain': {
const configResponse = await getConfigResponse(params.domain);
return json({ success: true, verified: !configResponse.misconfigured }, { status: 200 });
}
case 'add_domain': {
const addDomainResponse = await addDomainToVercel(params.domain);
return json({ success: true, data: addDomainResponse }, { status: 200 });
}
case 'remove_domain': {
const removeDomainResponse = await removeDomainFromVercelProject(params.domain);
return json({ success: true, data: removeDomainResponse }, { status: 200 });
}
default:
return json({ success: false, message: 'Invalid key' }, { status: 400 });
}
} catch (error) {
console.error(error);
return json({ success: false, message: 'Internal server error' }, { status: 500 });
}
}
@@ -1,26 +0,0 @@
import { json } from '@sveltejs/kit';
import sendEmail from '$mail/sendEmail';
export async function POST({ fetch, request }) {
const { to, content, orgName, exerciseTitle } = await request.json();
if (!to || !content || !orgName) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
const emailData = [
{
from: `"${orgName} (via ClassroomIO.com)" <[email protected]>`,
to,
subject: `[Submitted]: ${exerciseTitle}`,
content
}
];
await sendEmail(fetch)(emailData);
return json({
success: true,
message: 'Email sent'
});
}
@@ -1,84 +0,0 @@
import { getFeedForNotification } from '$lib/utils/services/newsfeed/index';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { json } from '@sveltejs/kit';
import sendEmail from '$mail/sendEmail';
const supabase = getServerSupabase();
const sendEmailNotification = async (sFetch: typeof fetch, feedId: string, authorId: string, comment?: string) => {
const feed = await getFeedForNotification({
supabase,
feedId,
authorId
});
console.log({ feed });
if (!feed) return;
const postLink = `https://${feed.org.siteName}.classroomio.com/courses/${feed.courseId}?feedId=${feed.id}`;
if (comment) {
const emailData = [
{
from: `"${feed.org.name} - ClassroomIO" <[email protected]>`,
to: feed.teacherEmail,
subject: `[${feed.courseTitle}] - News feed comment`,
content: `
<p>A student left you a comment on your newsfeed post</p>
<div style="font-style: italic; margin-top: 10px;">${comment}</div>
<div>
<a class="button" href="${postLink}">View comment</a>
</div>
`,
replyTo: '[email protected]'
}
];
await sendEmail(sFetch)(emailData);
// dont continue
return;
}
if (!feed.courseMembers.length) {
return;
}
// else send to everyone except the author of the post
const emailsData = feed.courseMembers.map((member) => ({
from: `"${feed.org.name} - ClassroomIO" <[email protected]>`,
to: member.email,
replyTo: feed.teacherEmail,
subject: `[${feed.courseTitle}] - New post in course`,
content: `
<p>${feed.teacherName} made a post in a course you are taking: ${feed.courseTitle}.</p>
<div style="font-style: italic; margin-top: 10px;">${feed.content}</div>
<div>
<a class="button" href="${postLink}">View post</a>
</div>
`
}));
console.log('Sending emails to all students', feed.courseMembers.length);
// This is the email sending function with a loop
await sendEmail(sFetch)(emailsData);
};
export async function POST({ fetch, request }) {
const { authorId, feedId, comment } = await request.json();
console.log('/POST api/email/course/newsfeed');
if (!authorId || !feedId) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
await sendEmailNotification(fetch, feedId, authorId, comment);
// TODO: Support sending to other platforms like telegram and discord
// sendDiscordNotification(); sendTelegramNotification();
return json({
success: true,
message: 'Email sent'
});
}
@@ -1,50 +0,0 @@
import { json } from '@sveltejs/kit';
// import { getSupabase } from '$lib/utils/functions/supabase';
import sendEmail from '$mail/sendEmail';
// const supabase = getSupabase();
export async function POST({ fetch, request }) {
const { to, courseName, teacherEmail, studentFullname, orgName } = await request.json();
// const accessToken = request.headers.get('Authorization') || '';
console.log('/POST api/email/course/student_prove_payment', to, courseName, teacherEmail, studentFullname, orgName);
if (!to || !courseName || !teacherEmail || !studentFullname || !orgName) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
// let user;
// try {
// const { data } = await supabase.auth.getUser(accessToken);
// user = data.user;
// } catch (error) {
// console.error(error);
// }
// if (!user) {
// return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
// }
const emailData = [
{
from: `"${orgName} - ClassroomIO" <[email protected]>`,
to,
replyTo: teacherEmail,
subject: `[${courseName}] One more step left`,
content: `
<p>Hi ${studentFullname},</p>
<p>You are one step closer to joining: <strong>${courseName}</strong></p>
<p>Please send your proof of payment to: <strong>${teacherEmail}</strong>, in order to join the course.</p>
<p>Talk to you soon and see you in class.</p>
<p>${orgName}</p>
`
}
];
await sendEmail(fetch)(emailData);
return json({
success: true,
message: 'Email sent'
});
}
@@ -1,34 +0,0 @@
import { json } from '@sveltejs/kit';
import sendEmail from '$mail/sendEmail';
// Sends email to student when they successfully join a course
export async function POST({ fetch, request }) {
const { to, orgName, courseName } = await request.json();
console.log('/POST api/email/course/student_welcome', to, orgName);
if (!to || !orgName || !courseName) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
const emailData = [
{
from: `"${orgName} (via ClassroomIO.com)" <[email protected]>`,
to,
subject: `${orgName} - Welcome to Class 🎉`,
content: `
<p>Hi there,</p>
<p>Congratulations 🎉, you’ve successfully joined: <strong>${courseName}</strong></p>
<p>Everything has been set up for you to have an amazing learning experience.</p>
<p>If you run into any issues, don’t fail to reach out to your instructor(s).</p>
<p>Cheers,</p>
<p>${orgName}</p>
`
}
];
await sendEmail(fetch)(emailData);
return json({
success: true,
message: 'Email sent'
});
}
@@ -1,26 +0,0 @@
import { json } from '@sveltejs/kit';
import sendEmail from '$mail/sendEmail';
export async function POST({ fetch, request }) {
const { to, content, orgName } = await request.json();
if (!to || !content || !orgName) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
const emailData = [
{
from: `"${orgName} (via ClassroomIO.com)" <[email protected]>`,
to,
subject: 'Submission Update',
content
}
];
await sendEmail(fetch)(emailData);
return json({
success: true,
message: 'Email sent'
});
}
@@ -1,34 +0,0 @@
import { json } from '@sveltejs/kit';
import sendEmail from '$mail/sendEmail';
export async function POST({ fetch, request }) {
const { to, courseName, studentEmail, studentFullname } = await request.json();
console.log('/POST api/email/course/teacher_student_buycourse', to, courseName, studentEmail, studentFullname);
if (!to || !courseName || !studentEmail || !studentFullname) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
const emailData = [
{
from: `"ClassroomIO" <[email protected]>`,
to,
subject: `[${courseName}] Request to Join Course!`,
content: `
<p>Hi amazing tutor,</p>
<p> A new student has requested to join a course you are teaching: “${courseName}"</p>
<p style="font: bold;">Student details</p>
<p>
Name: ${studentFullname}<br />
Email: ${studentEmail}
</p>
`
}
];
await sendEmail(fetch)(emailData);
return json({
success: true,
message: 'Email sent'
});
}
@@ -1,32 +0,0 @@
import { json } from '@sveltejs/kit';
import sendEmail from '$mail/sendEmail';
export async function POST({ fetch, request }) {
const { to, courseName, studentName, studentEmail } = await request.json();
console.log('/POST api/email/course/teacher_student_joined', to, courseName);
if (!to || !courseName || !studentName || !studentEmail) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
const emailData = [
{
from: `"ClassroomIO" <[email protected]>`,
to,
subject: `[${courseName}] You've got a new student 🎉!`,
content: `
<p>Hi amazing tutor,</p>
<p>Congratulations 🎉, a new student: <strong>${studentName} (${studentEmail})</strong> has joined a course you are teaching: ${courseName}</p>
<p>We hope they have a great experience learning from the best (YOU).</p>
<p>If you run into any issues, please don’t fail to reach out to us, we’d love to make your teaching experience as easy as possible.</p>
`
}
];
await sendEmail(fetch)(emailData);
return json({
success: true,
message: 'Email sent'
});
}
@@ -1,37 +0,0 @@
import { json } from '@sveltejs/kit';
import sendEmail from '$mail/sendEmail';
export async function POST({ fetch, request }) {
const { to, name, orgName, courseName, orgSiteName } = await request.json();
console.log('/POST api/email/course/teacher_welcome', to, name, orgName);
if (!to || !name || !orgName || !courseName || !orgSiteName) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
const origin = request.headers.get('origin');
const inviteLink = `${origin}/org/${orgSiteName}/courses`;
const emailData = [
{
from: `"${orgName} (via ClassroomIO.com)" <[email protected]>`,
to,
subject: `You have been invited to a course in ${orgName}!`,
content: `
<p>Hey ${name},</p>
<p> You have been given access to teach a course by ${orgName}</p>
<p>The course is titled: ${courseName}</p>
<div>
<a class="button" href="${inviteLink}">Open Dashboard</a>
</div>
`
}
];
await sendEmail(fetch)(emailData);
return json({
success: true,
message: 'Email sent'
});
}
@@ -1,46 +0,0 @@
import { json } from '@sveltejs/kit';
import sendEmail from '$mail/sendEmail';
// API to send invite to teacher
export async function POST({ fetch, request }) {
const body = await request.json();
const { org, email } = body;
console.log('/POST api/email/invite', body);
if (!org || !Object.keys(org).length || !email) {
return json({ success: false, message: 'Org data and Teacher name are required' }, { status: 400 });
}
const { id, name, siteName } = org;
const origin = request.headers.get('origin');
const inviteData = JSON.stringify({
email,
orgId: id,
orgSiteName: siteName
});
const inviteLink = `${origin}/invite/t/${encodeURIComponent(btoa(inviteData))}`;
console.log('inviteData', inviteData);
const emailData = [
{
from: `"Best from ClassroomIO" <[email protected]>`,
to: email,
subject: `Join ${name} on ClassroomIO 😃`,
content: `
<p>Hey there,</p>
<p> You have been invited to join ${name} on ClassroomIO 🎉🎉🎉.</p>
<div>
<a class="button" href="${inviteLink}">Accept Invitation</a>
</div>
`
}
];
await sendEmail(fetch)(emailData);
return json({
success: true,
message: 'Email sent'
});
}
@@ -1,86 +0,0 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
export const GET: RequestHandler = async ({ request, url }) => {
const userId = request.headers.get('user_id');
const orgId = url.searchParams.get('orgId');
if (!orgId) {
return json({ success: false, message: 'Organization ID is required' }, { status: 400 });
}
if (!userId) {
return json({ success: false, message: 'User ID is required' }, { status: 401 });
}
try {
const supabase = getServerSupabase();
// Check if user has access to this organization
const { data: orgMember } = await supabase
.from('organizationmember')
.select('role_id')
.eq('organization_id', orgId)
.eq('profile_id', userId)
.in('role_id', [1, 2]) // Admin or Member roles
.single();
if (!orgMember) {
return json(
{
success: false,
message: 'Access denied. User is not a member of this organization.'
},
{ status: 403 }
);
}
// Get all students who are participants in any course belonging to an org
const { data, error } = await supabase
.from('profile')
.select(
`
id,
fullname,
email,
avatar_url,
created_at,
groupmember!inner(
role_id,
group_id:group!inner(
organization_id
)
)
`
)
.eq('groupmember.group.organization_id', orgId)
.eq('groupmember.role_id', 3); // STUDENT role
if (error) {
throw new Error('Error fetching organization audience');
}
const audience = (data || []).map((profile) => ({
id: profile.id,
name: profile.fullname,
email: profile.email,
avatar_url: profile.avatar_url,
date_joined: new Date(profile.created_at).toDateString()
}));
return json({
success: true,
audience: audience
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error';
return json(
{
success: false,
message
},
{ status: 500 }
);
}
};
@@ -1,88 +0,0 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { ROLE, ROLE_LABEL } from '$lib/utils/constants/roles';
export const GET: RequestHandler = async ({ request, url }) => {
const orgId = url.searchParams.get('orgId');
const userId = request.headers.get('user_id');
if (!userId) {
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
}
if (!orgId) {
return json({ success: false, message: 'Organization ID is required' }, { status: 400 });
}
try {
const supabase = getServerSupabase();
// Check if user has access to this organization
const { data: orgMember } = await supabase
.from('organizationmember')
.select('role_id')
.eq('organization_id', orgId)
.eq('profile_id', userId)
.in('role_id', [1]) // Admin roles only
.single();
if (!orgMember) {
return json(
{
success: false,
message: 'Access denied. User is not a member of this organization.'
},
{ status: 403 }
);
}
// Fetch organization team
const { data, error } = await supabase
.from('organizationmember')
.select(
`
id,
email,
verified,
role_id,
profile(
id,
fullname,
email
)
`
)
.eq('organization_id', orgId)
.neq('role_id', ROLE.STUDENT)
.order('id', { ascending: false });
if (error) {
throw new Error('Error fetching organization team');
}
const team = (data || []).map((teamMember) => ({
id: teamMember.id,
email: teamMember?.profile?.email || teamMember.email,
verified: teamMember.verified,
profileId: teamMember?.profile?.id,
fullname: teamMember?.profile?.fullname || '',
role: ROLE_LABEL[teamMember?.role_id] || '',
isAdmin: teamMember?.role_id === ROLE.ADMIN
}));
return json({
success: true,
team: team
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error';
return json(
{
success: false,
message
},
{ status: 500 }
);
}
};
@@ -1,9 +1,9 @@
import { CustomerPortal } from '@polar-sh/sveltekit';
import { dev } from '$app/environment';
import { env } from '$env/dynamic/private';
import { CustomerPortal } from '@polar-sh/sveltekit';
export const GET = CustomerPortal({
accessToken: env.POLAR_ACCESS_TOKEN,
accessToken: env.POLAR_ACCESS_TOKEN!,
getCustomerId: async (e) => e.url.searchParams.get('customerId')!,
server: dev ? 'sandbox' : 'production'
});
@@ -1,33 +0,0 @@
import { json } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
const UNSPLASH_API_URL = `https://api.unsplash.com/search/photos?page=2&per_page=15&auto=format&fit=crop&w=2970&q=80&client_id=${env.UNSPLASH_API_KEY}`;
// API to fetch photos from Unsplash
export async function POST({ request }) {
const body = await request.json();
const { searchQuery } = body;
const query = searchQuery || 'rocks';
try {
const response = await fetch(`${UNSPLASH_API_URL}&query=${encodeURIComponent(query)}`, {
method: 'GET'
});
if (!response.ok) {
throw new Error('Unable to fetch photos from Unsplash API');
}
const data = await response.json();
return json({
success: true,
photos: data.results
});
} catch (error) {
console.error(error);
return json({ success: false, message: 'Error fetching photos from Unsplash' }, { status: 500 });
}
}
@@ -1,6 +1,6 @@
<script lang="ts">
import { CourseLandingPage } from '$features/ui';
import type { Course } from '$lib/utils/types';
import type { Course } from '$features/course/utils/types';
interface Data {
course: Course;
@@ -1,27 +1,43 @@
import type { MetaTagsProps } from 'svelte-meta-tags';
import { fetchCourse } from '$lib/utils/services/courses';
import { supabase, getSupabase } from '$lib/utils/functions/supabase';
if (!supabase) {
getSupabase();
}
import { classroomio } from '$lib/utils/services/api';
import type { Course } from '$features/course/types';
export const load = async ({ params = { slug: '' } }) => {
const { data } = await fetchCourse(undefined, params.slug);
let course: Course | null = null;
try {
const response = await classroomio.course.slug[':slug'].$get({
param: { slug: params.slug }
});
const result = await response.json();
if (result.success && result.data) {
course = result.data;
}
} catch (error) {
console.error('Failed to fetch course:', error);
}
if (!course) {
return {
slug: params.slug,
course: null,
pageMetaTags: null
};
}
const pageMetaTags = Object.freeze({
title: data?.title,
description: data?.description,
title: course.title,
description: course.description,
openGraph: {
title: data?.title,
description: data?.description,
title: course.title,
description: course.description,
images: [
{
url: data?.logo || '',
alt: data?.title,
url: course.logo || '',
alt: course?.title,
width: 280,
height: 200,
secureUrl: data?.logo,
secureUrl: course.logo,
type: 'image/jpeg'
}
]
@@ -30,16 +46,16 @@ export const load = async ({ params = { slug: '' } }) => {
handle: '@classroomio',
site: '@classroomio',
cardType: 'summary_large_image' as const,
title: data?.title,
description: data?.description,
image: data?.logo,
title: course?.title,
description: course?.description,
image: course?.logo,
imageAlt: 'ClassroomIO OG Image'
}
}) satisfies MetaTagsProps;
return {
slug: params.slug,
course: data,
course,
pageMetaTags
};
};
@@ -0,0 +1,104 @@
import { classroomio } from '$lib/utils/services/api';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
import { calcCourseDiscount } from '$lib/utils/functions/course';
import { redirect } from '@sveltejs/kit';
export const load = async ({ params, url }) => {
const slug = params?.slug ?? '';
const inviteToken = url.searchParams.get('invite_token') ?? null;
if (!slug) {
redirect(307, '/404');
}
if (inviteToken) {
try {
const response = await classroomio.invite.student[':token'].$get(
{ param: { token: inviteToken } },
getApiKeyHeaders()
);
const result = await response.json();
if (!result.success || !result.data) {
redirect(307, '/404');
}
const data = result.data;
const course = {
id: data.course.id,
slug: data.course.slug,
title: data.course.title,
description: data.course.description,
cost: data.course.cost ?? 0,
allowNewStudent: data.course.allowNewStudent ?? true,
status: data.course.status,
isPublished: data.course.isPublished
};
const currentOrg = {
id: data.organization.id,
name: data.organization.name,
siteName: data.organization.siteName ?? '',
theme: data.organization.theme ?? undefined
};
return {
course,
currentOrg,
invite: data.invite,
requiresPaymentOrInvite: false,
token: inviteToken
};
} catch (error) {
console.error('Enroll load: failed to load invite', error);
redirect(307, '/404');
}
}
try {
const response = await classroomio.course.slug[':slug'].$get({
param: { slug }
});
const result = await response.json();
if (!result.success || !result.data) {
redirect(307, '/404');
}
const courseData = result.data;
const discount = (courseData.metadata as { discount?: number } | null)?.discount ?? 0;
const showDiscount = (courseData.metadata as { showDiscount?: boolean } | null)?.showDiscount ?? false;
const calculatedCost = calcCourseDiscount(discount, Number(courseData.cost ?? 0), showDiscount);
const isFree = calculatedCost <= 0;
const currentOrg = courseData.org
? {
id: courseData.org.id,
name: courseData.org.name,
siteName: courseData.org.siteName ?? '',
theme: courseData.org.theme ?? undefined
}
: null;
const course = {
id: courseData.id,
slug: courseData.slug,
title: courseData.title,
description: courseData.description,
cost: courseData.cost ?? 0,
allowNewStudent: (courseData.metadata as { allowNewStudent?: boolean } | null)?.allowNewStudent ?? true,
status: courseData.status,
isPublished: courseData.isPublished
};
return {
course,
currentOrg,
invite: null,
requiresPaymentOrInvite: !isFree,
token: null as string | null
};
} catch (error) {
console.error('Enroll load: failed to load course by slug', error);
redirect(307, '/404');
}
};
@@ -0,0 +1,134 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { Button } from '@cio/ui/base/button';
import { AuthUI } from '$features/ui';
import { currentOrg } from '$lib/utils/store/org';
import { setTheme } from '$lib/utils/functions/theme';
import { courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import { profile } from '$lib/utils/store/user';
import { snackbar } from '$features/ui/snackbar/store';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { resolve } from '$app/paths';
let { data } = $props();
let loading = $state(false);
const inviteStatus = $derived(data.invite?.status ?? 'INVALID');
const canJoinCourse = $derived(
data.requiresPaymentOrInvite
? false
: (inviteStatus === 'ACTIVE' || !data.invite) &&
data.course?.allowNewStudent !== false &&
data.course?.status === 'ACTIVE' &&
Boolean(data.course?.isPublished)
);
function getBlockedMessage(): string {
if (data.requiresPaymentOrInvite) {
return t.get('course.navItem.landing_page.enroll_page.requires_payment_or_invite');
}
if (data.course?.allowNewStudent === false) {
return t.get('course.navItem.landing_page.pricing_section.not_accepting');
}
if (data.course?.status !== 'ACTIVE' || !data.course?.isPublished) {
return 'This course is currently unavailable for enrollment.';
}
if (data.invite && inviteStatus === 'EXPIRED') {
return 'This invite link has expired.';
}
if (data.invite && inviteStatus === 'USED_UP') {
return 'This invite link has reached its usage limit.';
}
if (data.invite && inviteStatus === 'REVOKED') {
return 'This invite link has been revoked.';
}
if (data.invite && inviteStatus !== 'ACTIVE') {
return 'This invite link is not valid.';
}
return '';
}
async function handleSubmit() {
if (!canJoinCourse) {
snackbar.error(getBlockedMessage());
return;
}
loading = true;
const redirectPath = page.url?.pathname ?? `/course/${data.course?.slug ?? ''}/enroll`;
const redirectSearch = data.token ? `?invite_token=${encodeURIComponent(data.token)}` : '';
const redirectUrl = `${redirectPath}${redirectSearch}`;
if (!$profile.id || !$profile.email) {
goto(resolve(`/signup?redirect=${encodeURIComponent(redirectUrl)}`, {}));
loading = false;
return;
}
try {
const body = data.token ? { inviteToken: data.token } : {};
const result = await courseApi.enroll(data.course!.id, body);
if (!result?.data) {
return;
}
capturePosthogEvent('student_joined_course', {
course_name: data.course?.title,
student_id: $profile.id,
student_email: $profile.email,
already_joined: result.data.alreadyJoined
});
goto(result.data.redirectTo || '/lms');
} finally {
loading = false;
}
}
$effect(() => {
if (!data.currentOrg) return;
setTheme(data.currentOrg?.theme || '');
currentOrg.update((o) => ({
...o,
id: data.currentOrg?.id ?? o.id,
name: data.currentOrg?.name ?? o.name,
siteName: data.currentOrg?.siteName ?? o.siteName,
theme: data.currentOrg?.theme ?? o.theme
}));
});
</script>
<svelte:head>
<title>Join {data.course?.title ?? 'Course'} on ClassroomIO</title>
</svelte:head>
<AuthUI isLogin={false} {handleSubmit} isLoading={loading || !$profile.id} showOnlyContent={true} showLogo={true}>
<div class="mt-0 w-full">
<h3 class="mt-0 mb-4 text-center text-lg font-medium dark:text-white">{data.course?.title}</h3>
<p class="text-center text-sm font-light dark:text-white">{data.course?.description}</p>
{#if data.requiresPaymentOrInvite}
<p class="ui:text-muted-foreground mt-3 text-center text-sm">
{$t('course.navItem.landing_page.enroll_page.requires_payment_or_invite')}
</p>
<a
href={resolve(`/course/${data.course?.slug ?? ''}`, {})}
class="ui:text-primary ui:underline mt-3 block text-center text-sm"
>
{$t('course.navItem.landing_page.enroll_page.back_to_course')}
</a>
{:else if !canJoinCourse}
<p class="mt-3 text-center text-sm text-red-500">{getBlockedMessage()}</p>
{/if}
</div>
<div class="my-4 flex w-full items-center justify-center">
<Button type="submit" disabled={!canJoinCourse || loading} loading={loading || !$profile.id}>
{$t('course.navItem.landing_page.enroll_page.join_course')}
</Button>
</div>
</AuthUI>
@@ -0,0 +1,50 @@
import { classroomio } from '$lib/utils/services/api';
import { redirect } from '@sveltejs/kit';
function normalizeTagsParam(rawTags: string | null): string[] {
const normalized = rawTags
?.split(',')
.map((value) => value.trim())
.filter(Boolean);
return normalized ?? [];
}
export const load = async ({ parent, url }) => {
const { isOrgSite, orgSiteName, org } = await parent();
if (!isOrgSite || !org) {
throw redirect(307, '/');
}
const siteName = orgSiteName || org.siteName;
if (!siteName) {
throw redirect(307, '/');
}
const normalizedTags = normalizeTagsParam(url.searchParams.get('tags'));
const normalizedTagsQuery = normalizedTags.length > 0 ? normalizedTags.join(',') : undefined;
const [coursesResponse, tagsResponse] = await Promise.all([
classroomio.organization.courses.public.$get({
query: {
siteName,
...(normalizedTagsQuery ? { tags: normalizedTagsQuery } : {})
}
}),
classroomio.organization.tags.public.$get({
query: {
siteName
}
})
]);
const [coursesPayload, tagsPayload] = await Promise.all([coursesResponse.json(), tagsResponse.json()]);
return {
org,
courses: coursesPayload.success ? coursesPayload.data : [],
tagGroups: tagsPayload.success ? tagsPayload.data : [],
activeTags: normalizedTags
};
};
@@ -0,0 +1,176 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';
import Navigation from '$lib/components/Navigation/index.svelte';
import { t } from '$lib/utils/functions/translations';
import { PoweredBy } from '$features/ui';
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
import { CourseCardList } from '$features/course/components';
import { Button } from '@cio/ui/base/button';
import { Checkbox } from '@cio/ui/base/checkbox';
import { Empty } from '@cio/ui/custom/empty';
import { Separator } from '@cio/ui/base/separator';
let { data } = $props();
let selectedTags = $derived<string[]>(data.activeTags || []);
async function applyTagFilters(nextTags: string[]) {
const params = new SvelteURLSearchParams(page.url.searchParams);
if (nextTags.length > 0) {
params.set('tags', nextTags.join(','));
} else {
params.delete('tags');
}
const query = params.toString();
await goto(resolve(`/courses?query=${query}`, {}), {
keepFocus: true,
noScroll: true,
invalidateAll: true
});
}
function toggleTag(tagSlug: string, checked: boolean) {
const next = new SvelteSet(selectedTags);
if (checked) {
next.add(tagSlug);
} else {
next.delete(tagSlug);
}
applyTagFilters(Array.from(next));
}
function clearFilters() {
if (selectedTags.length === 0) {
return;
}
applyTagFilters([]);
}
function isTagSelected(tagSlug: string) {
return selectedTags.includes(tagSlug);
}
const displayTagGroups = $derived.by(() => {
const sourceGroups = data.tagGroups ?? [];
const tagsByGroupId: Record<string, (typeof sourceGroups)[number]['tags']> = {};
for (const group of sourceGroups) {
for (const tag of group.tags ?? []) {
const groupId = tag.groupId || group.id;
const existing = tagsByGroupId[groupId] ?? [];
if (!existing.some((item) => item.id === tag.id)) {
existing.push(tag);
tagsByGroupId[groupId] = existing;
}
}
}
return sourceGroups.map((group) => ({
...group,
tags: tagsByGroupId[group.id] ?? []
}));
});
</script>
<svelte:head>
<title>{$t('public_courses.page_title')}</title>
</svelte:head>
<PoweredBy />
<main class="min-h-screen bg-white">
<Navigation
logo={data.org.avatarUrl || '/logo-192.png'}
orgName={data.org.name}
disableSignup={true}
isOrgSite={true}
customLinks={data.org.landingpage?.customLinks}
/>
<section class="mx-auto w-full max-w-7xl px-4 py-8 md:px-6">
<div class="mb-6 flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-semibold">{$t('public_courses.heading')}</h1>
<p class="ui:text-muted-foreground text-sm">{$t('public_courses.subtitle')}</p>
</div>
<Button variant="outline" onclick={clearFilters} disabled={selectedTags.length === 0}>
{$t('public_courses.clear_filters')}
</Button>
</div>
<div class="grid gap-8 lg:grid-cols-[280px_minmax(0,1fr)]">
<aside class="space-y-4 rounded-lg border p-4">
<div>
<h2 class="text-base font-semibold">{$t('public_courses.filters.title')}</h2>
<p class="ui:text-muted-foreground text-xs">{$t('public_courses.filters.help')}</p>
</div>
<Separator />
{#if displayTagGroups.length === 0}
<p class="ui:text-muted-foreground text-sm">{$t('public_courses.filters.empty')}</p>
{:else}
<div class="space-y-5">
{#each displayTagGroups as group (group.id)}
<div class="space-y-2">
<div class="space-y-1">
<h3 class="text-sm font-semibold">{group.name}</h3>
{#if group.description}
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
{/if}
</div>
<div class="space-y-2">
{#each group.tags as tag (tag.id)}
<label
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
>
<div class="flex items-center gap-2">
<Checkbox
checked={isTagSelected(tag.slug)}
onCheckedChange={(checked) => toggleTag(tag.slug, Boolean(checked))}
/>
<span
class="inline-block h-2.5 w-2.5 rounded-full border"
style={`background-color: ${tag.color}`}
aria-hidden="true"
></span>
<span class="text-sm">{tag.name}</span>
</div>
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
</label>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</aside>
<div class="space-y-4">
{#if data.courses.length === 0}
<Empty
icon={LibraryBigIcon}
title={$t('public_courses.empty.title')}
description={$t('public_courses.empty.description')}
variant="page"
/>
{:else}
<CourseCardList courses={data.courses} isOnLandingPage={true} />
{/if}
</div>
</div>
</section>
</main>
@@ -0,0 +1,12 @@
export const load = async ({ params }) => {
const courseId = params.id || '';
if (!courseId) {
return {
courseId: ''
};
}
return {
courseId
};
};
@@ -1,27 +1,120 @@
<script lang="ts">
import { goto } from '$app/navigation';
import * as Sidebar from '@cio/ui/base/sidebar';
import { Empty } from '@cio/ui/custom/empty';
import { Spinner } from '@cio/ui/base/spinner';
import { CourseSidebar } from '$features/course/components/sidebar';
import { CourseHeader } from '$features/course/components';
import * as Page from '@cio/ui/base/page';
import type { Course } from '$features/course/types';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { Confetti } from '$features/ui';
import { courseApi } from '$features/course/api';
import ContentCreateModal from '$features/course/components/content/content-create-modal.svelte';
import { profile } from '$lib/utils/store/user';
import { isOrgAdmin } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import type { CourseMember } from '$features/course/utils/types';
interface Props {
children?: import('svelte').Snippet;
path: string;
data: {
course?: Course;
courseId: string;
exerciseId?: string;
};
}
let { children, path }: Props = $props();
let { children, path, data }: Props = $props();
// Initialize course store on the client (fetch once, then reuse store data).
$effect(() => {
if (!data.courseId || !$profile.id) return;
if (data.course) {
courseApi.setCourse(data.course, $profile.id);
return;
}
courseApi.ensureCourse(data.courseId, $profile.id);
});
const isCourseReady = $derived.by(() => {
return courseApi.course?.id === data.courseId && !!courseApi.group.id;
});
const user: CourseMember | undefined = $derived(
courseApi.group.people.find((person) => person.profileId === $profile.id)
);
const canCheck = $derived(!!$profile.id && isCourseReady);
const isPermitted = $derived.by(() => {
if (!isCourseReady) return false;
if (!canCheck) return true;
if ($isOrgAdmin === null) return true;
return $isOrgAdmin || user;
});
const isExercisePage = $derived(!!data.exerciseId);
</script>
<Sidebar.Provider>
<CourseSidebar {path} />
<svelte:head>
<title>{courseApi.course?.title || 'ClassroomIO Course'}</title>
</svelte:head>
{#if isCourseReady}
<Dialog.Root open={!isPermitted}>
<Dialog.Content class="w-96">
<Dialog.Header>
<Dialog.Title>{$t('course.not_permitted.header')}</Dialog.Title>
</Dialog.Header>
<div>
<p class="text-md text-center dark:text-white">
{$t('course.not_permitted.body')}
</p>
<div class="mt-5 flex justify-center">
<Button
onclick={() => {
goto('/org/*');
}}
>
{$t('course.not_permitted.button')}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
{/if}
<Sidebar.Provider data-sveltekit-preload-data="off">
<CourseSidebar {path} id={data.courseId} {isCourseReady} />
<Sidebar.Inset
class="w-[calc(100vw-var(--sidebar-width))] overflow-x-hidden group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))]"
class="w-[calc(100vw-var(--sidebar-width))] group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))]"
>
<CourseHeader />
<ContentCreateModal />
{#if !isCourseReady}
<div class="mx-auto flex h-[calc(100vh-56px)] w-full items-center justify-center">
<Empty
title="Loading course…"
description="Please wait while we load your course data."
icon={Spinner}
iconClass="h-8 w-8"
variant="page"
/>
</div>
{:else}
{#if isExercisePage}
<Confetti />
{/if}
<Page.Root class="mx-auto flex px-4 lg:max-w-6xl">
{@render children?.()}
</Page.Root>
{/if}
</Sidebar.Inset>
</Sidebar.Provider>
@@ -1,16 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { NewsFeedPage } from '$features/course/pages';
import { CourseContainer } from '$lib/components/CourseContainer';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
import { RoleBasedSecurity } from '$features/ui';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { getGreeting } from '$lib/utils/functions/date.js';
import type { AccountOrg } from '$features/app/types';
import { isNewFeedModal } from '$lib/components/Course/components/NewsFeed/store';
import { newsfeedApi } from '$features/course/api';
let { data = $bindable() } = $props();
@@ -25,26 +22,21 @@
}
</script>
<svelte:head>
<title>News Feed - ClassroomIO</title>
</svelte:head>
<CourseContainer courseId={data.courseId}>
<RoleBasedSecurity
allowedRoles={getPageRoles($currentOrg)}
onDenied={() => {
goto(`/courses/${data.courseId}/lessons?next=true`);
}}
>
<RoleBasedSecurity
allowedRoles={getPageRoles($currentOrg)}
onDenied={() => {
goto(`/courses/${data.courseId}/lessons?next=true`);
}}
>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t(getGreeting())}
{$profile.fullname}!
{$t('course.navItem.news_feed.heading')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button class="mr-2" onclick={() => ($isNewFeedModal.open = true)}>
<Button class="mr-2" onclick={() => newsfeedApi.openNewFeedModal()}>
{$t('course.navItem.news_feed.heading_button.title')}
</Button>
</Page.Action>
@@ -55,5 +47,5 @@
<NewsFeedPage courseId={data.courseId} />
{/snippet}
</Page.Body>
</RoleBasedSecurity>
</CourseContainer>
</Page.Root>
</RoleBasedSecurity>
@@ -1,5 +1,5 @@
export const load = ({ params = { id: '' } }) => {
export const load = ({ params }) => {
return {
courseId: params.id
courseId: params.id || ''
};
};
@@ -0,0 +1,29 @@
import type { CourseAnalytics } from '$features/course/utils/types';
import { classroomio } from '$lib/utils/services/api';
import { getApiHeaders } from '$lib/utils/services/api';
export const load = async ({ params, cookies }) => {
const courseId = params.id || '';
if (!courseId) {
return {
courseId: '',
courseAnalytics: null
};
}
// Fetch analytics using single API call
const response = await classroomio.course[':courseId']['analytics'].$get(
{
param: { courseId }
},
getApiHeaders(cookies, '')
);
const data = await response.json();
const courseAnalytics: CourseAnalytics | null = data.success && data.data ? data.data : null;
return {
courseId,
courseAnalytics
};
};
@@ -1,5 +1,4 @@
<script lang="ts">
import { CourseContainer } from '$lib/components/CourseContainer';
import { AnalyticsPage } from '$features/course/pages';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
@@ -7,11 +6,7 @@
let { data } = $props();
</script>
<svelte:head>
<title>Analytics - ClassroomIO</title>
</svelte:head>
<CourseContainer courseId={data.courseId}>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
@@ -21,7 +16,7 @@
</Page.Header>
<Page.Body>
{#snippet child()}
<AnalyticsPage courseId={data.courseId} />
<AnalyticsPage courseId={data.courseId} courseAnalytics={data.courseAnalytics} />
{/snippet}
</Page.Body>
</CourseContainer>
</Page.Root>
@@ -1,7 +0,0 @@
export const load = ({ params }) => {
const { id } = params;
return {
courseId: id
};
};
@@ -1,5 +1,4 @@
<script lang="ts">
import { CourseContainer } from '$lib/components/CourseContainer';
import { AttendancePage } from '$features/course/pages';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
@@ -7,11 +6,7 @@
let { data } = $props();
</script>
<svelte:head>
<title>Attendance - ClassroomIO</title>
</svelte:head>
<CourseContainer courseId={data.courseId}>
<Page.Root class="mx-auto flex w-[calc(100vw-var(--sidebar-width))]!">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
@@ -24,4 +19,4 @@
<AttendancePage courseId={data.courseId} />
{/snippet}
</Page.Body>
</CourseContainer>
</Page.Root>
@@ -1,5 +1,5 @@
export const load = ({ params = { id: '' } }) => {
export const load = ({ params }) => {
return {
courseId: params.id
courseId: params.id || ''
};
};
@@ -1,17 +1,10 @@
<script lang="ts">
import { CertificatesPage } from '$features/course/pages';
import { CourseContainer } from '$lib/components/CourseContainer';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
let { data } = $props();
</script>
<svelte:head>
<title>Certificates - ClassroomIO</title>
</svelte:head>
<CourseContainer courseId={data.courseId}>
<Page.Root class="mx-auto flex w-[calc(100vw-var(--sidebar-width))]!">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
@@ -21,7 +14,7 @@
</Page.Header>
<Page.Body>
{#snippet child()}
<CertificatesPage courseId={data.courseId} />
<CertificatesPage />
{/snippet}
</Page.Body>
</CourseContainer>
</Page.Root>
@@ -1,5 +1,5 @@
export const load = ({ params = { id: '' } }) => {
export const load = ({ params }) => {
return {
courseId: params.id
courseId: params.id || ''
};
};
@@ -0,0 +1,40 @@
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
export const load = async ({ params, cookies }) => {
const courseId = params.id || '';
const exerciseId = params.exerciseId || '';
if (!courseId || !exerciseId) {
return { courseId, exerciseId, exercise: null, submissions: [], mySubmission: null };
}
const headers = getApiHeaders(cookies);
const [exerciseResponse, overviewResponse] = await Promise.all([
classroomio.course[':courseId'].exercise[':exerciseId'].$get({ param: { courseId, exerciseId } }, headers),
classroomio.course[':courseId'].exercise[':exerciseId']['submissions'].$get(
{ param: { courseId, exerciseId } },
headers
)
]);
const exerciseResult = await exerciseResponse.json();
const exercise = exerciseResult.success && exerciseResult.data ? exerciseResult.data : null;
const overviewResult = await overviewResponse.json();
const overview =
overviewResult.success && overviewResult.data ? overviewResult.data : { mySubmission: [], allSubmissions: [] };
const mySubmissionData = Array.isArray(overview.mySubmission) ? overview.mySubmission : [];
const mySubmission = mySubmissionData.length > 0 ? mySubmissionData[0] : null;
const submissions = Array.isArray(overview.allSubmissions) ? overview.allSubmissions : [];
return {
courseId,
exerciseId,
exercise,
submissions,
mySubmission
};
};
@@ -0,0 +1,90 @@
<script lang="ts">
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { Empty } from '@cio/ui/custom/empty';
import LockIcon from '@lucide/svelte/icons/lock';
import * as Page from '@cio/ui/base/page';
import { ExercisePage } from '$features/course/pages';
import {
questionnaire,
questionnaireMetaData,
clearQuestionnaireValidation,
reset
} from '$features/course/components/exercise/store';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import type { Question } from '$features/course/types';
import {
getQuestionTypeId,
getQuestionTypeOptionById
} from '$features/course/components/exercise/question-type-utils';
import { normalizeQuestionOrder } from '$features/course/components/exercise/order-utils';
let { data = $bindable() } = $props();
const path = `/courses/${data.courseId}/lessons`;
const isLockedForStudent = $derived($globalStore.isStudent && data.exercise?.isUnlocked === false);
$effect(() => {
if (!data.exercise || isLockedForStudent) return;
const meta = get(questionnaireMetaData);
if (meta.isFinished && meta.exerciseId === data.exerciseId) {
return;
}
if (meta.exerciseId != null && meta.exerciseId !== data.exerciseId) {
reset();
}
clearQuestionnaireValidation();
const exercise = data.exercise;
// Transform questions and set questionnaire store
let questions: Question[] = [];
if (exercise.questions && Array.isArray(exercise.questions)) {
const mapped = exercise.questions.map((question) => {
const questionType = getQuestionTypeOptionById(getQuestionTypeId(question));
return {
...question,
questionTypeId: questionType.id,
questionType
};
});
questions = normalizeQuestionOrder(mapped);
}
questionnaire.set({
title: exercise.title,
description: exercise.description,
dueBy: exercise.dueBy,
isTitleDirty: false,
isDescriptionDirty: false,
isDueByDirty: false,
questions: questions,
totalSubmissions: 0
});
questionnaireMetaData.update((m) => ({ ...m, exerciseId: data.exerciseId }));
});
</script>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
{#if isLockedForStudent}
<Empty
title={$t('course.navItem.lessons.content_locked_title')}
description={$t('course.navItem.lessons.content_locked_description')}
icon={LockIcon}
variant="page"
class="text-center"
/>
{:else}
<ExercisePage
exerciseId={data.exerciseId}
goBack={() => goto(resolve(path, {}))}
isFetching={false}
submissions={data.submissions ?? []}
mySubmission={data.mySubmission ?? null}
/>
{/if}
</Page.Root>
@@ -1,47 +1,47 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { course } from '$lib/components/Course/store';
import type { Course } from '$lib/utils/types';
import { lessons, lessonSections } from '$lib/components/Course/components/Lesson/store/lessons';
import { CourseContainer } from '$lib/components/CourseContainer';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { courseApi } from '$features/course/api';
import type { Course } from '$features/course/utils/types';
import { CourseLandingPage } from '$features/ui';
import Editor from '$features/ui/course-landing-page/components/editor/editor.svelte';
import * as Sidebar from '@cio/ui/base/sidebar';
let { data } = $props();
const { courseId } = data;
const courseId = $derived(data.courseId);
let courseData: Course = $derived({
...$course,
$lessons,
$lessonSections
});
let sidebarOpen = $state(true);
function syncCourseStore(_courseData: Course) {
$course = _courseData;
if (courseApi.course) {
courseApi.course = { ...courseApi.course, ..._courseData };
}
}
function handleClose() {
goto(resolve(`/courses/${courseId}`, {}));
}
</script>
<CourseContainer {courseId} renderOnlyChildren={true}>
{#if courseApi.course}
<div
class="absolute inset-0 z-50 flex bg-white"
class="fixed inset-0 z-50 h-screen w-screen bg-white"
in:fly={{ y: 500, duration: 500 }}
out:fly={{ y: 500, duration: 500 }}
>
<Editor {courseId} bind:course={courseData} {syncCourseStore} />
<div class="rightBar">
<CourseLandingPage bind:courseData editMode={true} />
</div>
<Sidebar.Provider bind:open={sidebarOpen}>
<Sidebar.Root side="left" collapsible="icon" class="h-full {sidebarOpen ? 'w-[280px]!' : 'w-0'}">
<Editor {courseId} bind:course={courseApi.course} {syncCourseStore} onClose={handleClose} />
</Sidebar.Root>
<Sidebar.Inset class="relative h-screen! overflow-y-auto">
<div class="absolute top-2 left-2 z-60">
<Sidebar.Trigger variant="secondary" />
</div>
<CourseLandingPage bind:courseData={courseApi.course} editMode={true} />
</Sidebar.Inset>
</Sidebar.Provider>
</div>
</CourseContainer>
<style>
.rightBar {
flex-grow: 1;
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
}
</style>
{/if}
@@ -1,5 +1,5 @@
export const load = ({ params = { id: '' } }) => {
export const load = ({ params }) => {
return {
courseId: params.id
courseId: params.id || ''
};
};
@@ -1,54 +1,42 @@
<script lang="ts">
import { LessonsPage } from '$features/course/pages';
import { CourseContainer } from '$lib/components/CourseContainer';
import { Button } from '@cio/ui/base/button';
import { RoleBasedSecurity } from '$features/ui';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { profile } from '$lib/utils/store/user';
import { COURSE_VERSION } from '$lib/utils/types';
import { getGreeting } from '$lib/utils/functions/date.js';
import { course } from '$lib/components/Course/store';
import { handleAddLessonWidget } from '$lib/components/Course/components/Lesson/store';
import { contentCreateStoreUtils, contentEditingStore } from '$features/course/components/content/store';
import { courseApi } from '$features/course/api';
let { data } = $props();
let reorder = $state(false);
let activateSections = $state(false);
let lessonEditing: string | undefined;
function addLesson() {
$handleAddLessonWidget.open = true;
$handleAddLessonWidget.isSection = $course.version === COURSE_VERSION.V2;
function addContent() {
const contentGroupingEnabled = courseApi.course?.metadata?.isContentGroupingEnabled ?? true;
if (contentGroupingEnabled) {
contentCreateStoreUtils.openSection();
} else {
contentCreateStoreUtils.openDefault();
}
}
</script>
<svelte:head>
<title>Lessons - ClassroomIO</title>
</svelte:head>
<CourseContainer courseId={data.courseId}>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t(getGreeting())}
{$profile.fullname}!
{$t('course.navItem.lessons.heading_v2')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<div class="flex w-full justify-end gap-2">
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if $course.version === COURSE_VERSION.V1}
<Button variant="outline" onclick={() => (activateSections = !activateSections)} disabled={!!lessonEditing}>
{$t(`course.navItem.lessons.section_prompt.cta`)}
</Button>
{/if}
<Button variant="outline" onclick={() => (reorder = !reorder)} disabled={!!lessonEditing}>
<Button variant="outline" onclick={() => (reorder = !reorder)} disabled={!!$contentEditingStore}>
{$t(`course.navItem.lessons.add_lesson.${reorder ? 'end_reorder' : 'start_reorder'}`)}
</Button>
<Button onclick={addLesson} disabled={!!lessonEditing}>
{$t('course.navItem.lessons.add_lesson.button_title')}
</Button>
<Button onclick={addContent} disabled={!!$contentEditingStore}
>{$t('course.navItem.lessons.add_content')}</Button
>
</RoleBasedSecurity>
</div>
</Page.Action>
@@ -56,7 +44,7 @@
<Page.Body>
{#snippet child()}
<LessonsPage courseId={data.courseId} />
<LessonsPage courseId={data.courseId} bind:reorder />
{/snippet}
</Page.Body>
</CourseContainer>
</Page.Root>
@@ -1,5 +1,5 @@
export const load = ({ params = { id: '' } }) => {
export const load = ({ params }) => {
return {
courseId: params.id
courseId: params.id || ''
};
};
@@ -1,14 +0,0 @@
export const load = ({ params }) => {
const { id: courseId, lessonParams = '' } = params;
const splitparams = lessonParams.split('/');
const [lessonId, exerciseRouteName, exerciseId] = splitparams;
return {
courseId,
lessonId,
exerciseRouteName,
exerciseId,
isMaterialsTabActive: !exerciseRouteName
};
};
@@ -1,344 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import Save from '@lucide/svelte/icons/save';
import * as Select from '@cio/ui/base/select';
import Pencil from '@lucide/svelte/icons/pencil';
import HistoryIcon from '@lucide/svelte/icons/history';
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { CircleCheckIcon } from '$features/ui/icons';
import MODES from '$lib/utils/constants/mode';
import { profile } from '$lib/utils/store/user';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { course, group } from '$lib/components/Course/store';
import { LANGUAGES } from '$lib/utils/constants/translation';
import { getGroupMemberId } from '$lib/components/Course/function';
import { getIsLessonComplete } from '$lib/components/Course/components/Lesson/functions';
import { COURSE_VERSION, type Lesson, type LessonCompletion } from '$lib/utils/types';
import LessonVersionHistory from '$lib/components/Course/components/Lesson/LessonVersionHistory.svelte';
import { checkExercisesComplete, fetchLesson, updateLessonCompletion } from '$lib/utils/services/courses';
import { lesson, setLesson, lessons, lessonSections } from '$lib/components/Course/components/Lesson/store/lessons';
import { IconButton } from '@cio/ui/custom/icon-button';
import * as Page from '@cio/ui/base/page';
import { CourseContainer } from '$lib/components/CourseContainer';
import { RoleBasedSecurity } from '$features/ui';
import Exercises from '$lib/components/Course/components/Lesson/Exercises/index.svelte';
import Materials from '$lib/components/Course/components/Lesson/Materials/index.svelte';
let { data = $bindable() } = $props();
let mode = $state(MODES.view);
let prevMode = $state('');
let isMarkingComplete = $state(false);
let isSaving = $state(false);
let isVersionDrawerOpen = $state(false);
let hasFetched = $state('');
const path = $derived(page.url?.pathname?.replace(/\/exercises[\/ 0-9 a-z -]*/, ''));
const isLessonComplete = $derived(getIsLessonComplete($lesson.lesson_completion, $profile.id));
async function fetchReqData(lessonId = '', isMaterialsTabActive: boolean) {
if (lessonId === $lesson.id) return;
if (!lessonId || hasFetched === lessonId) return;
hasFetched = lessonId;
$lesson.isFetching = true;
console.log('fetching', $lesson.isFetching);
let lessonData;
if (isMaterialsTabActive) {
const lesson = await fetchLesson(lessonId);
lessonData = lesson.data;
}
console.log({ lessonData });
const totalExercises = lessonData?.totalExercises?.[0]?.count || 0;
const totalComments = lessonData?.totalComments?.[0]?.count || 0;
setLesson({
id: lessonId,
lessonData,
totalExercises,
totalComments,
locale: $profile.locale || 'en'
});
$lesson.isFetching = false;
}
async function markLessonComplete(lessonId: string) {
const groupMemberId = getGroupMemberId($group.people, $profile.id);
const { data: areExercisesComplete, error } = await checkExercisesComplete(lessonId, groupMemberId);
if (error) {
snackbar.error('snackbar.lessons.error.try_later');
return;
}
if (!areExercisesComplete) {
snackbar.error('snackbar.lessons.error.exercise_not_complete');
return;
}
isMarkingComplete = true;
let newCompletion: LessonCompletion = {
is_complete: true,
profile_id: $profile.id || '',
lesson_id: lessonId,
created_at: new Date().toDateString(),
updated_at: new Date().toDateString()
};
let updatedCompletion: LessonCompletion;
const completions: LessonCompletion[] = $lesson.lesson_completion.map((completion) => {
if (completion.profile_id === $profile.id) {
console.log('shouldUpdate');
completion = {
...completion,
is_complete: !completion.is_complete
};
updatedCompletion = completion;
}
return completion;
});
// @ts-ignore
if (updatedCompletion) {
await updateLessonCompletion(updatedCompletion, true);
} else {
completions.push(newCompletion);
await updateLessonCompletion(newCompletion, false);
}
$lesson.lesson_completion = completions;
$lessons = $lessons.map((l) => {
if (l.id === $lesson.id) {
l.lesson_completion = completions;
}
return l;
});
snackbar.success('snackbar.lessons.success.complete_marked');
isMarkingComplete = false;
}
function toggleMode() {
prevMode = mode;
mode = mode === MODES.edit ? MODES.view : MODES.edit;
}
const getLessons = () => {
if ($course.version === COURSE_VERSION.V1) {
return $lessons;
} else {
const _lessons: Lesson[] = [];
$lessonSections.forEach((section) => {
_lessons.push(...section.lessons);
});
return _lessons;
}
};
const isNextOrPrevDisabled = (lessonId: string, isPrev: boolean) => {
const _lessons = getLessons();
const index = _lessons.findIndex((lesson) => lesson.id === lessonId);
return isPrev ? !_lessons[index - 1] : !_lessons[index + 1];
};
const goToNextOrPrevLesson = (lessonId: string, isPrev: boolean) => {
const _lessons = getLessons();
const isDisabled = isNextOrPrevDisabled(lessonId, isPrev);
// Always use early return
if (isDisabled) return;
const index = _lessons.findIndex((lesson) => lesson.id === lessonId);
const nextOrPrevLesson = isPrev ? _lessons[index - 1] : _lessons[index + 1];
const isLocked = $globalStore.isStudent && !nextOrPrevLesson.is_unlocked;
if (isLocked) return;
const path = `/courses/${$course.id}/lessons/${nextOrPrevLesson.id}`;
goto(path);
};
const refetchDataAfterVersionRestore = () => {
isVersionDrawerOpen = false;
if (data.courseId && browser) {
mode = MODES.view;
hasFetched = '';
fetchReqData(data.lessonId, data.isMaterialsTabActive);
}
snackbar.success('snackbar.lessons.success.version_restored');
};
$effect(() => {
if (data.courseId && $profile.id) {
fetchReqData(data.lessonId, data.isMaterialsTabActive);
}
});
const isPrevDisabled = $derived(isNextOrPrevDisabled(data.lessonId, true));
const isNextDisabled = $derived(isNextOrPrevDisabled(data.lessonId, false));
</script>
<CourseContainer
{path}
containerClass="relative"
courseId={data.courseId}
isExercisePage={!data.isMaterialsTabActive && !!data.exerciseId}
>
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{data.isMaterialsTabActive
? $t('course.navItem.lessons.lesson_nav.materials')
: $t('course.navItem.lessons.lesson_nav.exercises')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<div class="flex items-center gap-1">
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if data.isMaterialsTabActive}
<!-- Version control -->
{#if mode === MODES.edit && window.innerWidth >= 1024}
<IconButton onclick={() => (isVersionDrawerOpen = true)}>
<HistoryIcon size={20} />
</IconButton>
{/if}
<div class="flex-row items-center lg:flex">
<IconButton
onclick={() => {
toggleMode();
}}
disabled={isSaving}
>
{#if mode === MODES.edit}
<Save size={20} />
{:else}
<Pencil size={20} />
{/if}
</IconButton>
</div>
<Select.Root type="single" bind:value={$lesson.locale}>
<Select.Trigger class="h-9 w-[120px]">
{LANGUAGES.find((lang) => lang.id === $lesson.locale)?.text || 'Language'}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each LANGUAGES as lang}
<Select.Item value={lang.id}>
{lang.text}
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
{/if}
</RoleBasedSecurity>
</div>
</Page.Action>
</Page.Header>
{#if !data.isMaterialsTabActive}
<Exercises lessonId={data.lessonId} exerciseId={data.exerciseId} path={`${path}/exercises`} />
{:else if !!data.lessonId}
<div class="overflow-x-hidden lg:w-full xl:w-11/12">
<!-- Shows Lesson Note / Slides / Video -->
<Materials
lessonId={data.lessonId}
{mode}
{prevMode}
{toggleMode}
bind:isSaving
isStudent={$globalStore.isStudent}
/>
</div>
{/if}
<!-- Bottom Lesson Widget -->
<div class="absolute bottom-5 flex w-full items-center justify-center">
<div class="flex w-fit items-center gap-2 rounded-full bg-gray-100 px-5 py-1 shadow-xl dark:bg-neutral-700">
<button
disabled={isPrevDisabled}
class={`my-2 flex items-center border border-b-0 border-l-0 border-t-0 border-gray-300 px-2 pr-4 ${
isPrevDisabled && 'cursor-not-allowed opacity-25'
}`}
onclick={() => goToNextOrPrevLesson(data.lessonId, true)}
>
<ChevronLeftIcon size={16} />
<span class="hidden md:block">{$t('course.navItem.lessons.prev')}</span>
</button>
{#if data.isMaterialsTabActive}
<button
class="my-2 flex items-center border border-b-0 border-l-0 border-t-0 border-gray-300 px-2 pr-4"
onclick={() => goto(`${path}/exercises`)}
>
<ListChecksIcon size={16} />
<span class="ml-1">{$lesson.totalExercises}</span>
</button>
{:else}
<button
class="my-2 flex items-center border border-b-0 border-l-0 border-t-0 border-gray-300 px-2 pr-4"
onclick={() => goto(path)}
>
<LibraryBigIcon size={16} />
</button>
{/if}
<button
class="my-2 flex items-center border border-b-0 border-l-0 border-t-0 border-gray-300 px-2 pr-4"
onclick={() => markLessonComplete(data.lessonId)}
disabled={isMarkingComplete}
>
<CircleCheckIcon filled={isLessonComplete} />
</button>
<button
disabled={isNextDisabled}
class={`my-2 flex items-center px-2 ${isNextDisabled && 'cursor-not-allowed opacity-25'}`}
onclick={() => goToNextOrPrevLesson(data.lessonId, false)}
>
<span class="hidden md:block">{$t('course.navItem.lessons.next')}</span>
<ChevronRightIcon size={16} />
</button>
</div>
</div>
<!-- Version Control Preview -->
{#if isVersionDrawerOpen && window.innerWidth >= 1024}
<LessonVersionHistory
open={isVersionDrawerOpen}
on:close={() => (isVersionDrawerOpen = false)}
on:restore={refetchDataAfterVersionRestore}
/>
{/if}
</CourseContainer>
<style>
@media screen and (min-width: 1023px) {
.tab {
display: none;
}
}
</style>
@@ -0,0 +1,25 @@
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
export const load = async ({ params, cookies }) => {
const courseId = params.id || '';
const lessonId = params.lessonId || '';
if (!courseId || !lessonId) {
return { courseId, lessonId, lesson: null };
}
const response = await classroomio.course[':courseId'].lesson[':lessonId'].$get(
{ param: { courseId, lessonId } },
getApiHeaders(cookies)
);
const result = await response.json();
const lesson = result.success && result.data ? result.data : null;
return {
courseId,
lessonId,
lesson: lesson
};
};
@@ -0,0 +1,43 @@
<script lang="ts">
import { LessonPage } from '$features/course/pages';
import { lessonApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
import type { Lesson } from '$features/course/utils/types';
import * as Page from '@cio/ui/base/page';
interface Props {
data: {
courseId: string;
lessonId: string;
lesson: Lesson | null;
};
}
let { data }: Props = $props();
$effect(() => {
if (!data.lesson) return;
const lesson = data.lesson;
if (lessonApi.lesson?.id === lesson.id) return;
// Set lesson data
lessonApi.lesson = lesson;
// Set translations if lesson has lessonLanguages
if (lesson.lessonLanguages) {
lessonApi.setTranslations();
}
// Set current locale from profile if available
if ($profile.locale) {
lessonApi.currentLocale = $profile.locale;
}
});
</script>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
{#key data.lessonId}
<LessonPage courseId={data.courseId} lessonId={data.lessonId} />
{/key}
</Page.Root>
@@ -0,0 +1,25 @@
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
import type { MarksPageData } from '$features/course/utils/marks-utils';
export const load = async ({ params, cookies }) => {
const courseId = params.id || '';
if (!courseId) {
return {
courseId: '',
marksData: null
};
}
const response = await classroomio.course[':courseId'].mark.gradebook.$get(
{ param: { courseId } },
getApiHeaders(cookies, '')
);
const json = await response.json();
const marksData: MarksPageData | null = json.success && json.data ? (json.data as MarksPageData) : null;
return {
courseId,
marksData
};
};
@@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { CourseContainer } from '$lib/components/CourseContainer';
import { resolve } from '$app/paths';
import { MarksPage } from '$features/course/pages';
import * as Page from '@cio/ui/base/page';
import { RoleBasedSecurity } from '$features/ui';
@@ -11,25 +11,14 @@
import { Progress } from '@cio/ui/base/progress';
import DownloadIcon from '@lucide/svelte/icons/download';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import jsPDF from 'jspdf';
import Papa from 'papaparse';
import autoTable from 'jspdf-autotable';
import { course } from '$lib/components/Course/store';
import { lessons } from '$lib/components/Course/components/Lesson/store/lessons';
import type { GroupPerson } from '$lib/utils/types';
import { getLectureNo } from '$lib/components/Course/function.js';
import { courseApi } from '$features/course/api';
import { generateMarksCSV, generateMarksPDF } from '$features/course/utils/marks-utils';
let { data } = $props();
let isDownloading = $state(false);
let students: GroupPerson[] = $state([]);
let lessonMapping: Record<string, Record<string, { title: string; points: number }>> = $state({});
let studentMarksByExerciseId: Record<string, Record<string, string>> = $state({});
function calculateStudentTotal(studentExerciseData) {
if (!studentExerciseData) return 0;
return Object.values(studentExerciseData).reduce((total: number, point) => (total += parseInt(point as string)), 0);
}
const exercises = $derived(data.marksData?.exercises ?? []);
function getPageRoles(org: AccountOrg) {
const roles = [1, 2];
@@ -40,99 +29,45 @@
}
const downloadCSV = () => {
isDownloading = true;
const exportData = students.map((student) => {
const rowData = {
name: student.profile.fullname,
email: student.profile.email,
Total: 0
};
const totalPoints = calculateStudentTotal(studentMarksByExerciseId[student.id]);
$lessons.forEach((lesson, lessonIndex) => {
const quizzes = lessonMapping[lesson.id] || {};
const quizMark = studentMarksByExerciseId[student.id] || {};
Object.keys(quizzes).forEach((quizId) => {
const quiz = quizzes[quizId];
const key = `L${lessonIndex + 1} - ${quiz.title} (${lesson.title})`;
rowData[key] = quizMark[quizId] || '-';
});
});
rowData['Total'] = totalPoints;
return rowData;
});
const csv = Papa.unparse(exportData);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `${$course?.title}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
isDownloading = false;
};
function downloadPDF() {
if (!data.marksData) return;
isDownloading = true;
try {
const doc = new jsPDF({ orientation: 'landscape' });
const head = [['Student']];
$lessons.forEach((lesson, lessonIndex) => {
if (lessonMapping[lesson.id]) {
Object.values(lessonMapping[lesson.id]).forEach((exercise) => {
head[0].push(`${getLectureNo(lessonIndex + 1)} - ${exercise.title} (${lesson.title})`);
});
}
});
head[0].push('Total');
const body = students.map((student) => {
const row: string[] = [];
row.push(student?.profile?.fullname ?? '');
$lessons.forEach((lesson) => {
if (lessonMapping[lesson.id]) {
Object.keys(lessonMapping[lesson.id]).forEach((exerciseId) => {
const mark: string = studentMarksByExerciseId?.[student.id]?.[exerciseId] || '-';
row.push(mark);
});
}
});
const total = '' + calculateStudentTotal(studentMarksByExerciseId[student.id]);
row.push(total);
return row;
});
autoTable(doc, {
head,
body,
startY: 20,
styles: { fontSize: 8 },
headStyles: { fillColor: [22, 160, 133] },
footStyles: { fillColor: [22, 160, 133] },
didDrawPage: function (data) {
doc.setFontSize(14);
doc.setTextColor(40);
doc.text(`${$course?.title} - Marks`, data.settings.margin.left, 10);
}
});
doc.save(`${$course?.title}-marks.pdf`);
generateMarksCSV(
data.marksData.students,
exercises,
data.marksData.studentMarksByExerciseId,
courseApi.course?.title || 'Course'
);
} finally {
isDownloading = false;
}
};
const downloadPDF = () => {
if (!data.marksData) return;
isDownloading = true;
try {
generateMarksPDF(
data.marksData.students,
exercises,
data.marksData.studentMarksByExerciseId,
courseApi.course?.title || 'Course'
);
} catch (error) {
console.error('Error generating PDF:', error);
} finally {
isDownloading = false;
}
}
};
</script>
<svelte:head>
<title>Marks - ClassroomIO</title>
</svelte:head>
<CourseContainer courseId={data.courseId}>
<RoleBasedSecurity
allowedRoles={getPageRoles($currentOrg)}
onDenied={() => {
goto(`/courses/${data.courseId}/lessons?next=true`);
}}
>
<RoleBasedSecurity
allowedRoles={getPageRoles($currentOrg)}
onDenied={() => {
goto(resolve(`/courses/${data.courseId}/lessons?next=true`, {}));
}}
>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
@@ -168,8 +103,8 @@
<Page.Body>
{#snippet child()}
<MarksPage />
<MarksPage marksData={data.marksData} />
{/snippet}
</Page.Body>
</RoleBasedSecurity>
</CourseContainer>
</Page.Root>
</RoleBasedSecurity>
@@ -1,5 +0,0 @@
export const load = ({ params = { id: '' } }) => {
return {
courseId: params.id
};
};
@@ -7,7 +7,6 @@
import { t } from '$lib/utils/functions/translations';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import * as Page from '@cio/ui/base/page';
import { CourseContainer } from '$lib/components/CourseContainer';
let { data = $bindable(), children } = $props();
@@ -28,36 +27,34 @@
};
</script>
<CourseContainer courseId={data.courseId}>
<div class="mx-auto w-full max-w-3xl">
<Page.Header>
{#if data.personId}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<IconButton onclick={handleBackNavigation}>
<ArrowLeftIcon size={16} />
</IconButton>
</RoleBasedSecurity>
{/if}
<Page.HeaderContent>
<Page.Title>
{$t('course.navItem.people.title')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
{#if !data.personId}
<Page.Root class="mx-auto w-[90%] md:max-w-3xl">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{#if data.personId}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Button class="mr-2" onclick={handleClick}>
{$t('course.navItem.people.add')}
</Button>
<IconButton onclick={handleBackNavigation}>
<ArrowLeftIcon size={16} />
</IconButton>
</RoleBasedSecurity>
{/if}
</Page.Action>
</Page.Header>
{$t('course.navItem.people.title')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
{#if !data.personId}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Button class="mr-2" onclick={handleClick}>
{$t('course.navItem.people.add')}
</Button>
</RoleBasedSecurity>
{/if}
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
{@render children?.()}
{/snippet}
</Page.Body>
</div>
</CourseContainer>
<Page.Body>
{#snippet child()}
{@render children?.()}
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,6 +1,6 @@
export const load = ({ params = { id: '', personId: '' } }) => {
export const load = ({ params }) => {
return {
courseId: params.id,
personId: params.personId
courseId: params.id || '',
personId: params.personId || ''
};
};
@@ -0,0 +1,41 @@
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
import type { UserCourseAnalytics } from '$features/course/utils/types';
export const load = async ({ params, cookies }) => {
const courseId = params.id || '';
const userId = params.personId;
if (!courseId || !userId) {
return {
courseId,
userId,
userCourseAnalytics: null
};
}
try {
const response = await classroomio.course[':courseId']['members'][':userId']['analytics'].$get(
{
param: { courseId, userId }
},
getApiHeaders(cookies, '')
);
const result = await response.json();
const userCourseAnalytics: UserCourseAnalytics | null = result.success && result.data ? result.data : null;
return {
courseId,
userId,
userCourseAnalytics
};
} catch (error) {
console.error('Failed to fetch user course analytics:', error);
return {
courseId,
userId,
userCourseAnalytics: null
};
}
};
@@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { Badge } from '@cio/ui/base/badge';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
@@ -7,16 +6,14 @@
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { getAccessToken } from '$lib/utils/functions/supabase';
import type { UserCourseAnalytics } from '$lib/utils/types/analytics';
import type { UserCourseAnalytics } from '$features/course/utils/types';
import { Progress } from '@cio/ui/base/progress';
import { ActivityCard, HeroProfileCard, LoadingPage } from '$features/ui';
let { data } = $props();
let userCourseAnalytics: UserCourseAnalytics | undefined = $state();
let userCourseAnalytics: UserCourseAnalytics | undefined = $state(data.userCourseAnalytics ?? undefined);
function getPercentage(a: number, b: number): number {
if (b === 0) {
@@ -34,29 +31,6 @@
}
}
async function fetchUserCourseAnalytics() {
const accessToken = await getAccessToken();
const response = await fetch('/api/analytics/user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify(data)
});
if (response.ok) {
userCourseAnalytics = (await response.json()) as UserCourseAnalytics;
} else {
console.error(response);
snackbar.error('Failed to fetch analytics data');
}
}
onMount(() => {
fetchUserCourseAnalytics();
});
let learningActivities = $derived([
{
description: $t('analytics.overall_course_progress_user_description'),
@@ -95,11 +69,17 @@
let incompleteExercises = $derived(
userCourseAnalytics?.userExercisesStats?.filter((exercise) => !exercise.isCompleted)?.length || 0
);
$effect(() => {
console.log('userCourseAnalytics.user', userCourseAnalytics?.user);
});
</script>
{#if userCourseAnalytics}
<section class="px-1">
<HeroProfileCard user={userCourseAnalytics.user} />
{#if userCourseAnalytics.user}
<HeroProfileCard user={userCourseAnalytics.user} />
{/if}
<div class="mt-5 px-0">
<div class="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
@@ -109,7 +89,7 @@
</div>
</div>
<div class="mt-5 rounded-md border p-3 md:p-5 dark:border-neutral-600">
<div class="mt-5 rounded-md border p-3 md:p-5">
<h3 class="text-2xl">
{$t('analytics.exercises')}
</h3>
@@ -130,7 +110,7 @@
<div class="flex items-center justify-between">
<Badge
type={exerciseFilter === 'incomplete' ? 'secondary' : 'outline'}
class="lowercase text-yellow-700 dark:text-yellow-500"
class="text-yellow-700 lowercase dark:text-yellow-500"
onclick={() => toggleExerciseFilter('incomplete')}
>
{incompleteExercises}
@@ -138,7 +118,7 @@
</Badge>
<Badge
type={exerciseFilter === 'completed' ? 'gray' : 'outline'}
class="lowercase text-green-700 dark:text-green-500"
class="text-green-700 lowercase dark:text-green-500"
onclick={() => toggleExerciseFilter('completed')}
>
{completedExercises}
@@ -161,19 +141,22 @@
<BookOpenIcon size={16} />
<div>
<div class="mb-2">
<a
href={`/courses/${data.courseId}/lessons/${exercise.lessonId}/exercises/${exercise.id}?tabIndex=1`}
>
<a href={`/courses/${data.courseId}/exercises/${exercise.id}`}>
<p class="text-lg font-semibold text-gray-600">
{exercise.title}
</p>
</a>
<a href={`/courses/${data.courseId}/lessons/${exercise.lessonId}`}>
{#if exercise.lessonId}
<a href={`/courses/${data.courseId}/lessons/${exercise.lessonId}`}>
<p class="text-sm text-gray-500">
#{exercise.lessonTitle}
</p>
</a>
{:else if exercise.lessonTitle}
<p class="text-sm text-gray-500">
#{exercise.lessonTitle}
</p>
</a>
{/if}
</div>
<p class="text-sm text-gray-500">
Score: {exercise.score}/{exercise.totalPoints}
@@ -1,8 +0,0 @@
export const load = async ({ params }) => {
const { id, personId } = params;
return {
userId: personId,
courseId: id
};
};
@@ -2,11 +2,8 @@
import { CourseSettingsPage } from '$features/course/pages';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { CourseContainer } from '$lib/components/CourseContainer';
import { Button } from '@cio/ui/base/button';
let { data } = $props();
let settingsComponent: CourseSettingsPage | null = $state(null);
let isSaving = $state(false);
@@ -20,28 +17,22 @@
}
</script>
<svelte:head>
<title>Settings - ClassroomIO</title>
</svelte:head>
<CourseContainer courseId={data.courseId}>
<div class="mx-auto w-full max-w-3xl">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t('course.navItem.settings.heading')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="secondary" loading={isSaving} onclick={handleSave}>
{$t('course.navItem.settings.save')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<CourseSettingsPage bind:this={settingsComponent} />
{/snippet}
</Page.Body>
</div>
</CourseContainer>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
<Page.Header class="sticky top-13 z-10 bg-white">
<Page.HeaderContent>
<Page.Title>
{$t('course.navItem.settings.heading')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="secondary" loading={isSaving} onclick={handleSave}>
{$t('course.navItem.settings.save')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<CourseSettingsPage bind:this={settingsComponent} />
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,5 +1,5 @@
export const load = ({ params = { id: '' } }) => {
export const load = ({ params }) => {
return {
courseId: params.id
courseId: params.id || ''
};
};
@@ -0,0 +1,38 @@
import { classroomio } from '$lib/utils/services/api';
import { getApiHeaders } from '$lib/utils/services/api';
export const load = async ({ params, cookies }) => {
const courseId = params.id || '';
if (!courseId) {
return {
courseId: '',
sections: [],
submissionIdData: {}
};
}
// Fetch pre-processed submission data server-side
const response = await classroomio.course[':courseId'].submission['for-grading'].$get(
{
param: { courseId }
},
getApiHeaders(cookies)
);
const result = await response.json();
if (result.success && result.data) {
return {
courseId,
sections: result.data.sections || [],
submissionIdData: result.data.submissionIdData || {}
};
}
return {
courseId,
sections: [],
submissionIdData: {}
};
};
@@ -1,6 +1,5 @@
<script lang="ts">
import { SubmissionsPage } from '$features/course/pages';
import { CourseContainer } from '$lib/components/CourseContainer';
import { RoleBasedSecurity } from '$features/ui';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
@@ -8,12 +7,8 @@
let { data } = $props();
</script>
<svelte:head>
<title>Submissions - ClassroomIO</title>
</svelte:head>
<CourseContainer courseId={data.courseId}>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Page.Root class="mx-auto flex w-[calc(95vw-var(--sidebar-width))]!">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
@@ -24,8 +19,12 @@
<Page.Body>
{#snippet child()}
<SubmissionsPage courseId={data.courseId} />
<SubmissionsPage
courseId={data.courseId}
sections={data.sections || []}
submissionIdData={data.submissionIdData || {}}
/>
{/snippet}
</Page.Body>
</RoleBasedSecurity>
</CourseContainer>
</Page.Root>
</RoleBasedSecurity>
@@ -1,5 +0,0 @@
export const load = ({ params = { id: '' } }) => {
return {
courseId: params.id
};
};
@@ -1,27 +0,0 @@
import { OrgApiServer } from '$features/org/api/org.server';
import { redirect } from '@sveltejs/kit';
export const load = async ({ params = { hash: '' } }) => {
try {
const courseHashData = atob(decodeURIComponent(params.hash));
console.log('courseHashData', courseHashData);
const { id, name, description, orgSiteName } = JSON.parse(courseHashData);
if (!id || !name || !description || !orgSiteName) {
throw 'Validation failed';
}
const currentOrg = await OrgApiServer.getOrgBySiteName(orgSiteName);
return {
id,
name,
description,
currentOrg
};
} catch (error) {
console.error('Error decoding course invite params.hash', error);
redirect(307, '/404');
}
};
@@ -1,135 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { Button } from '@cio/ui/base/button';
import { getSupabase } from '$lib/utils/functions/supabase';
import { AuthUI } from '$features/ui';
import { currentOrg } from '$lib/utils/store/org';
import { setTheme } from '$lib/utils/functions/theme';
import { addGroupMember } from '$lib/utils/services/courses';
import { ROLE } from '@cio/utils/constants';
import { profile } from '$lib/utils/store/user';
import { triggerSendEmail, NOTIFICATION_NAME } from '$lib/utils/services/notification/notification';
import { snackbar } from '$features/ui/snackbar/store';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { page } from '$app/state';
let { data } = $props();
let supabase = getSupabase();
let loading = $state(false);
let disableSubmit = false;
async function handleSubmit() {
loading = true;
if (!$profile.id || !$profile.email) {
console.log('Profile not found', $profile);
return goto(`/signup?redirect=${page.url?.pathname || ''}`);
}
const { data: courseData, error } = await supabase.from('course').select('group_id').eq('id', data.id).single();
console.log({ courseData });
if (!courseData?.group_id) {
console.error('error getting group', error);
return;
}
const member = {
profile_id: $profile.id,
group_id: courseData.group_id,
role_id: ROLE.STUDENT
};
const teacherMembers = await supabase
.from('groupmember')
.select('id, profile(email)')
.eq('group_id', courseData.group_id)
.eq('role_id', ROLE.TUTOR)
.returns<
{
id: string;
profile: {
email: string;
};
}[]
>();
const teachers: Array<string> =
teacherMembers.data?.map((teacher) => {
return teacher.profile?.email || '';
}) || [];
addGroupMember(member).then((addedMember) => {
if (addedMember.error) {
console.error('Error adding student to group', courseData.group_id, addedMember.error);
snackbar.error('snackbar.invite.failed_join');
// Full page load to lms if error joining, probably user already joined
window.location.href = '/lms';
return;
}
capturePosthogEvent('student_joined_course', {
course_name: data.name,
student_id: $profile.id,
student_email: $profile.email
});
// Send email welcoming student to the course
triggerSendEmail(NOTIFICATION_NAME.STUDENT_COURSE_WELCOME, {
to: $profile.email,
orgName: data.currentOrg?.name,
courseName: data.name
});
// Send notification to all teacher(s) that a student has joined the course.
Promise.all(
teachers.map((email) =>
triggerSendEmail(NOTIFICATION_NAME.TEACHER_STUDENT_JOINED, {
to: email,
courseName: data.name,
studentName: $profile.fullname,
studentEmail: $profile.email
})
)
);
// go to lms
return goto('/lms');
});
}
onMount(async () => {
// check if user has session, if not redirect to sign up with redirect back to this page
const {
data: { session }
} = await supabase.auth.getSession();
if (!session) {
return goto(`/login?redirect=${page.url?.pathname || ''}`);
}
if (!data.currentOrg) return;
setTheme(data.currentOrg?.theme || '');
currentOrg.set(data.currentOrg);
});
</script>
<svelte:head>
<title>Join {data.name} on ClassroomIO</title>
</svelte:head>
<AuthUI isLogin={false} {handleSubmit} isLoading={loading || !$profile.id} showOnlyContent={true} showLogo={true}>
<div class="mt-0 w-full">
<h3 class="mb-4 mt-0 text-center text-lg font-medium dark:text-white">{data.name}</h3>
<p class="text-center text-sm font-light dark:text-white">{data.description}</p>
</div>
<div class="my-4 flex w-full items-center justify-center">
<Button type="submit" disabled={disableSubmit || loading} loading={loading || !$profile.id}>Join Course</Button>
</div>
</AuthUI>
@@ -1,31 +1,35 @@
import { OrgApiServer } from '$features/org/api/org.server';
import { getProfile } from '$lib/utils/functions/user';
import { getOrgBySiteName } from '$features/org/api/org.server';
import { classroomio } from '$lib/utils/services/api';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
import { redirect } from '@sveltejs/kit';
// we need to know if the email exists or not.
// with this we can only ask the user to accept
export const load = async ({ params = { hash: '' } }) => {
try {
const hashData = atob(decodeURIComponent(params.hash));
console.log('hashData', hashData);
const token = decodeURIComponent(params.hash);
const response = await classroomio.invite.organization[':token'].preview.$get(
{
param: { token }
},
getApiKeyHeaders()
);
const result = await response.json();
const { orgId, email, orgSiteName } = JSON.parse(hashData);
if (!result.success || !result.data) {
throw new Error('Invalid organization invite payload');
}
const currentOrg = await OrgApiServer.getOrgBySiteName(orgSiteName);
const profile = await getProfile(email);
console.log('profile data', profile);
const apiKeyHeaders = getApiKeyHeaders();
const currentOrg = result.data.organization.siteName
? await getOrgBySiteName(result.data.organization.siteName, apiKeyHeaders)
: null;
return {
invite: {
orgId,
email,
currentOrg,
profile
}
token,
invite: result.data,
currentOrg
};
} catch (error) {
console.error('Error decoding invite params.hash', error);
console.error('Error decoding organization invite params.hash', error);
redirect(307, '/404');
}
};
@@ -1,268 +1,110 @@
<script lang="ts">
import { page } from '$app/state';
import { AuthUI } from '$features/ui';
import type { Profile } from '$lib/components/Course/components/People/types';
import { InputField } from '@cio/ui/custom/input-field';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import { Password } from '@cio/ui/custom/password';
import { SIGNUP_FIELDS } from '$lib/utils/constants/authentication';
import { logout } from '$lib/utils/functions/logout';
import { getSupabase } from '$lib/utils/functions/supabase';
import { AuthUI } from '$features/ui';
import { currentOrg } from '$lib/utils/store/org';
import { setTheme } from '$lib/utils/functions/theme';
import { t } from '$lib/utils/functions/translations';
import { profile, user } from '$lib/utils/store/user';
import { authValidation, getConfirmPasswordError, getDisableSubmit } from '$lib/utils/functions/validator';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import { onMount, untrack } from 'svelte';
import { classroomio } from '$lib/utils/services/api';
import { profile } from '$lib/utils/store/user';
import { snackbar } from '$features/ui/snackbar/store';
import { page } from '$app/state';
let { data } = $props();
let supabase = getSupabase();
let fields = $state(Object.assign({}, SIGNUP_FIELDS));
let loading = $state(false);
let isLoggingOut = $state(false);
let shouldLogout = $state(false);
let errors: {
name?: string;
email?: string;
password?: string;
} = $state({});
const inviteStatus = $derived(data.invite?.invite?.status ?? 'INVALID');
const isInviteEmailMismatch = $derived(
Boolean(
$profile.email &&
data.invite?.invite?.email &&
$profile.email.toLowerCase() !== data.invite.invite.email.toLowerCase()
)
);
const canJoinOrganization = $derived(inviteStatus === 'ACTIVE' && !isInviteEmailMismatch);
let submitError: string = $state('');
const confirmPasswordError = $derived(getConfirmPasswordError(fields));
const disableSubmit = $derived(getDisableSubmit(fields));
async function joinOrg(profileId: string, email: string | null) {
if (!profileId || !email || !data.invite.currentOrg?.id) return;
// Update member response
const updateMemberRes = await supabase
.from('organizationmember')
.update({
verified: true,
profile_id: profileId
})
.match({ email: email, organization_id: data.invite.currentOrg?.id });
console.log('Update member response', updateMemberRes);
window.location.href = $currentOrgPath;
}
async function signUserIn(profileId: string, email: string) {
if (!profileId || !email) {
throw $t('login.validation.unable_to_create_profile');
function getBlockedInviteMessage(): string {
if (isInviteEmailMismatch) {
return 'You are logged in with a different email from this invite.';
}
const res = await supabase.auth.signInWithPassword({
email,
password: fields.password
});
if (res.error) {
throw res.error;
if (inviteStatus === 'EXPIRED') {
return 'This invite link has expired.';
}
if (inviteStatus === 'REVOKED') {
return 'This invite link has been revoked.';
}
if (inviteStatus === 'ACCEPTED') {
return 'This invite has already been used.';
}
return 'This invite link is not valid.';
}
async function handleSubmit() {
if ($profile && $profile.id) {
loading = true;
await joinOrg($profile.id, $profile.email);
if (inviteStatus !== 'ACTIVE') {
snackbar.error(getBlockedInviteMessage());
return;
}
const validationRes = authValidation({
...fields,
email: data.invite.email // validation for this ema
});
console.log('validationRes', validationRes);
if (!$profile.id || !$profile.email) {
return goto(`/signup?redirect=${page.url?.pathname || ''}`);
}
if (Object.keys(validationRes).length) {
errors = Object.assign(errors, validationRes);
if (isInviteEmailMismatch) {
snackbar.error(getBlockedInviteMessage());
return;
}
loading = true;
try {
loading = true;
let profile: Profile | null = data.invite.profile;
const response = await classroomio.invite.organization[':token'].accept.$post({
param: { token: data.token }
});
const result = await response.json();
if (!data.invite.profile) {
// Signup
const { data: signupData, error } = await supabase.auth.signUp({
email: data.invite.email,
password: fields.password
});
console.log('Signup', signupData);
if (error) throw error;
if (!signupData.user) {
throw $t('login.validation.user_cannot_be_created');
}
// Insert profile
const userId = signupData.user.id;
const profileRes = await supabase
.from('profile')
.insert({
id: userId,
username: fields.name.toLowerCase().replace(' ', '-') + new Date().getTime(),
fullname: fields.name,
email: data.invite.email
})
.select();
console.log('Insert profile', profileRes.data);
if (profileRes.error) {
throw profileRes.error;
}
profile = profileRes.data[0] || {};
if (!result.success || !result.data) {
snackbar.error(result.error || 'Failed to join organization');
return;
}
if (!profile?.id) {
throw $t('login.validation.unable_to_create_profile');
}
await signUserIn(profile.id, profile.email);
await joinOrg(profile.id, profile.email);
goto(result.data.redirectTo || '/org');
} catch (error) {
if (error instanceof Error) {
submitError = error.message;
} else {
submitError = error?.toString() || '';
}
console.error('Failed to accept organization invite', error);
snackbar.error('Failed to join organization');
} finally {
loading = false;
}
}
onMount(async () => {
if (!data.invite.currentOrg) return;
if (!data.currentOrg) return;
setTheme(data.invite.currentOrg?.theme || '');
currentOrg.set(data.invite.currentOrg);
});
const isLoading = $derived(loading || $user.fetchingUser);
$effect(() => {
const email = $profile?.email;
if (!email) return;
if (email !== data.invite.email) {
console.log('logout');
snackbar.error('You are logged in with a different email');
untrack(() => {
shouldLogout = true;
});
}
setTheme(data.currentOrg?.theme || '');
currentOrg.set(data.currentOrg);
});
</script>
<svelte:head>
<title>Join ClassroomIO</title>
<title>Join {data.invite.organization.name} on ClassroomIO</title>
</svelte:head>
<AuthUI redirectPathname={page.url.pathname} isLogin={false} {handleSubmit} {isLoading} showLogo={true}>
<div class="mt-4 w-full {shouldLogout ? 'hidden' : ''}">
<p class="mb-6 text-lg font-semibold dark:text-white">
{#if data.invite.profile}
{$t('login.login_to_join')}
{:else}
{$t('login.create_to_join')}
{/if}
</p>
<InputField
label={$t('login.fields.email')}
value={data.invite.email}
type="email"
placeholder="[email protected]"
className="mb-6"
isDisabled={true}
/>
{#if $profile?.email !== data.invite.email}
{#if !data.invite.profile}
<InputField
label={$t('login.fields.full_name')}
bind:value={fields.name}
type="text"
autoFocus={true}
placeholder="e.g Joke Silva"
className="mb-6"
isDisabled={isLoading}
errorMessage={errors.name}
isRequired
/>
{/if}
<Field.Field class="mb-6">
<Field.Label for="password">{$t('login.fields.password')}</Field.Label>
<Field.Content>
<Password
id="password"
bind:value={fields.password}
placeholder="************"
disabled={isLoading}
aria-invalid={errors.password ? 'true' : undefined}
autocomplete={data.invite.profile ? 'current-password' : 'new-password'}
/>
{#if errors.password}
<Field.Error>{$t(errors.password)}</Field.Error>
{:else}
<Field.Description>{$t('login.fields.password_helper_message')}</Field.Description>
{/if}
</Field.Content>
</Field.Field>
{#if !data.invite.profile}
<Field.Field class="mb-6">
<Field.Label for="confirm-password">{$t('login.fields.confirm_password')}</Field.Label>
<Field.Content>
<Password
id="confirm-password"
bind:value={fields.confirmPassword}
placeholder="************"
disabled={isLoading}
aria-invalid={confirmPasswordError ? 'true' : undefined}
autocomplete="new-password"
/>
{#if confirmPasswordError}
<Field.Error>{$t(confirmPasswordError)}</Field.Error>
{/if}
</Field.Content>
</Field.Field>
{/if}
{/if}
{#if submitError}
<p class="text-sm text-red-500">{submitError}</p>
<AuthUI isLogin={false} {handleSubmit} isLoading={loading} showOnlyContent={true} showLogo={true}>
<div class="mt-0 w-full">
<h3 class="mt-0 mb-4 text-center text-lg font-medium dark:text-white">{data.invite.organization.name}</h3>
<p class="text-center text-sm font-light dark:text-white">Role: {data.invite.invite.roleLabel}</p>
<p class="mt-1 text-center text-sm font-light dark:text-white">Invited email: {data.invite.invite.email}</p>
{#if !canJoinOrganization}
<p class="mt-3 text-center text-sm text-red-500">{getBlockedInviteMessage()}</p>
{/if}
</div>
<div class="my-4 flex w-full items-center justify-end">
{#if shouldLogout}
<Button
type="button"
loading={isLoggingOut}
variant="destructive"
onclick={async () => {
isLoggingOut = true;
await logout(false);
isLoggingOut = false;
shouldLogout = false;
}}
>
Logout
</Button>
{:else}
<Button type="submit" disabled={disableSubmit} loading={isLoading}>Accept Invite</Button>
{/if}
<div class="my-4 flex w-full items-center justify-center">
<Button type="submit" disabled={!canJoinOrganization || loading} {loading}>Join Organization</Button>
</div>
</AuthUI>
+1 -1
View File
@@ -16,7 +16,7 @@
<Sidebar.Inset>
<LmsHeader />
<div class="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-4 px-4">
<div class="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-4 px-4">
{@render children?.()}
</div>
</Sidebar.Inset>
@@ -22,7 +22,7 @@
<title>Settings - ClassroomIO</title>
</svelte:head>
<Page.Header>
<Page.Header isSticky>
<Page.HeaderContent>
<Page.Title>{$t('settings.profile.heading')}</Page.Title>
</Page.HeaderContent>
@@ -1,18 +1,44 @@
import { getOrgBySiteName } from '$features/org/api/org.server';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
import { env } from '$env/dynamic/private';
import { getSubdomain } from '$features/app/layout-setup';
import { redirect } from '@sveltejs/kit';
const APP_SUBDOMAINS = env.PRIVATE_APP_SUBDOMAINS?.split(',') || ['app'];
const ORG_ID_COOKIE_PREFIX = 'cio_org_id_';
export const load = async ({ params, url }) => {
export const load = async ({ params, url, cookies }) => {
const subdomain = getSubdomain(url);
const isOrgSite = subdomain && !APP_SUBDOMAINS.includes(subdomain);
// If this is LMS but user is on org site, redirect to LMS
if (isOrgSite) {
redirect(307, `/lms`);
}
const siteName = params.slug;
const cookieKey = `${ORG_ID_COOKIE_PREFIX}${siteName}`;
const cachedOrgId = cookies.get(cookieKey);
let orgId: string | undefined = cachedOrgId;
if (!orgId) {
const apiKeyHeaders = getApiKeyHeaders();
const org = await getOrgBySiteName(siteName, apiKeyHeaders);
if (org?.id) {
orgId = org.id;
cookies.set(cookieKey, org.id, {
path: '/',
maxAge: 60 * 60 * 24 * 7, // 7 days
sameSite: 'lax',
httpOnly: false // Allow client-side access if needed
});
}
}
return {
orgName: params.slug
orgName: siteName,
orgId
};
};
@@ -5,6 +5,7 @@
import { Skeleton } from '@cio/ui/base/skeleton';
import { currentOrg } from '$lib/utils/store/org';
import { AppHeader } from '$features/ui';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
import { VerifyEmailModal } from '$features/onboarding/components';
@@ -13,7 +14,7 @@
let { data, children } = $props();
function redirect(siteName: string) {
function redirect(siteName: string | null) {
if (!siteName) return;
const newUrl = page.url.pathname.replace('*', siteName);
@@ -25,7 +26,9 @@
});
</script>
<AddOrgModal />
{#if PUBLIC_IS_SELFHOSTED !== 'true'}
<AddOrgModal />
{/if}
<VerifyEmailModal />
@@ -0,0 +1,34 @@
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
// TODO - Replace with actual cache
const cache = {};
export const load = async ({ params, parent, cookies }) => {
const { orgId } = await parent();
const siteName = params.slug;
if (!orgId) {
return {
orgName: siteName,
stats: null
};
}
if (cache[orgId]) {
return {
orgName: siteName,
stats: cache[orgId]
};
}
// Fetch dashboard stats
const statsResponse = await classroomio.dash.stats.$get({ query: { siteName } }, getApiHeaders(cookies, orgId));
const statsData = await statsResponse.json();
cache[orgId] = statsData.success ? statsData.data : null;
return {
orgName: siteName,
stats: cache[orgId]
};
};
@@ -11,7 +11,6 @@
import { calDateDiff } from '$lib/utils/functions/date';
import { getGreeting } from '$lib/utils/functions/date';
import { currentOrgPath } from '$lib/utils/store/org';
import { dashStatApi } from '$features/org/api';
import { CreateCourseButton } from '$features/course/components';
import * as Avatar from '@cio/ui/base/avatar';
@@ -25,11 +24,12 @@
const { data } = $props();
const { enrollments, numberOfCourses, revenue, totalStudents, topCourses } = $derived(dashStatApi.stats);
$effect(() => {
dashStatApi.fetchOrgStats({ siteName: data.orgName });
});
const stats = $derived(data.stats);
const enrollments = $derived(stats?.enrollments || []);
const numberOfCourses = $derived(stats?.numberOfCourses || 0);
const revenue = $derived(stats?.revenue || 0);
const totalStudents = $derived(stats?.totalStudents || 0);
const topCourses = $derived(stats?.topCourses || []);
let cards = $derived([
{
@@ -81,19 +81,13 @@
<div class="mb-10 flex flex-wrap items-start">
<div class="grid w-full grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each cards as card}
{#if !revenue && !numberOfCourses && !totalStudents}
<Skeleton style="width: 100%; height: 10rem;" class="rounded-md" />
{:else}
<ActivityCard activity={card} />
{/if}
<ActivityCard activity={card} />
{/each}
</div>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div
class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600 dark:text-white"
>
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:text-white">
<h3 class="mb-4 text-lg">
{$t('dashboard.top_courses')}
</h3>
@@ -139,9 +133,7 @@
</div>
</div>
<div
class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600 dark:text-white"
>
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:text-white">
<h3 class="mb-4 text-lg">
{$t('dashboard.recent_enrollments')}
</h3>
@@ -164,7 +156,7 @@
</Avatar.Root>
<div class="min-h-[45px] space-y-1">
<p class="text-sm capitalize leading-none">{enrollment.name}</p>
<p class="text-sm leading-none capitalize">{enrollment.name}</p>
<p class="text-muted-foreground text-sm">
<span class="italic">
{calDateDiff(enrollment.date)}
@@ -1,5 +0,0 @@
export const load = ({ params = { slug: '' } }) => {
return {
orgName: params.slug
};
};
@@ -0,0 +1,21 @@
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
export const load = async ({ parent, cookies }) => {
const { orgId } = await parent();
if (!orgId) {
return {
audience: []
};
}
// Fetch organization audience
const audienceResponse = await classroomio.organization.audience.$get({}, getApiHeaders(cookies, orgId));
const audienceData = await audienceResponse.json();
const audience = audienceData.success ? audienceData.data : [];
return {
audience
};
};
@@ -5,8 +5,8 @@
import { currentOrgPlan, currentOrgMaxAudience } from '$lib/utils/store/org';
import { PLAN } from '@cio/utils/plans';
import * as Page from '@cio/ui/base/page';
import { orgApi } from '$features/org/api/org.svelte';
let { data } = $props();
let isLoading = $state(false);
function exportAudience() {
@@ -14,6 +14,8 @@
alert('This feature is coming soon');
isLoading = false;
}
const audienceLength = $derived(data.audience?.length || 0);
</script>
<svelte:head>
@@ -27,7 +29,7 @@
{$t('audience.title')}
{#if $currentOrgPlan?.planName !== PLAN.ENTERPRISE}
<span class="ml-2 text-sm">
({orgApi.audience.length} / {$currentOrgMaxAudience})
({audienceLength} / {$currentOrgMaxAudience})
</span>
{/if}
</Page.Title>
@@ -40,7 +42,7 @@
</Page.Header>
<Page.Body>
{#snippet child()}
<AudiencePage />
<AudiencePage audience={data.audience} />
{/snippet}
</Page.Body>
</Page.Root>
@@ -0,0 +1,30 @@
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
export const load = async ({ params, parent, cookies }) => {
const { orgId } = await parent();
const paramParts = params.params?.split('/') ?? [];
const userId = paramParts[0];
if (!userId || !orgId) {
return {
userId,
orgId,
analytics: null
};
}
const analyticsResponse = await classroomio.organization.audience[':userId'].analytics.$get(
{ param: { userId } },
getApiHeaders(cookies, orgId)
);
const analyticsData = await analyticsResponse.json();
const analytics = analyticsData.success ? analyticsData.data : null;
return {
userId,
orgId,
analytics
};
};
@@ -1,11 +0,0 @@
export const load = async ({ params }) => {
const paramParts = params.params?.split('/') ?? [];
const userId = paramParts[0];
const orgId = paramParts[1];
return {
userId,
orgId
};
};
@@ -0,0 +1,22 @@
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
export const load = async ({ parent, cookies }) => {
const { orgId } = await parent();
if (!orgId) {
return {
questions: []
};
}
// Fetch community questions
const questionsResponse = await classroomio.community.$get({ query: { orgId } }, getApiHeaders(cookies, orgId));
const questionsData = await questionsResponse.json();
const questions = questionsData.success ? questionsData.data : [];
return {
questions,
orgId
};
};
@@ -3,13 +3,15 @@
import { AskCommunityButton } from '$features/community/components';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
let { data } = $props();
</script>
<svelte:head>
<title>Community - ClassroomIO</title>
</svelte:head>
<Page.Root class="w-full">
<Page.Root class="mx-auto w-full max-w-3xl">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('community.title')}</Page.Title>
@@ -20,7 +22,7 @@
</Page.Header>
<Page.Body>
{#snippet child()}
<CommunityPage />
<CommunityPage questions={data.questions} />
{/snippet}
</Page.Body>
</Page.Root>
@@ -0,0 +1,19 @@
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
export const load = async ({ params, parent, cookies }) => {
const { orgId } = await parent();
const slug = params.cslug || '';
const questionResponse = await classroomio.community[':slug'].$get(
{ param: { slug } },
getApiHeaders(cookies, orgId)
);
const questionData = await questionResponse.json();
const question = questionData.success ? questionData.data : null;
return {
slug,
question
};
};
@@ -8,4 +8,4 @@
<title>Question - ClassroomIO</title>
</svelte:head>
<CommunityQuestionPage slug={data.slug} isLMS={false} />
<CommunityQuestionPage slug={data.slug} question={data.question} isLMS={false} />
@@ -1,5 +0,0 @@
export const load = ({ params = { slug: '' } }) => {
return {
slug: params.slug
};
};
@@ -0,0 +1,42 @@
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
export const load = async ({ parent, locals, cookies, url }) => {
const tagsParam = url.searchParams.get('tags');
const { orgId } = await parent();
if (!orgId || !locals.user?.id) {
return {
courses: []
};
}
const normalizedTags = tagsParam
?.split(',')
.map((value) => value.trim())
.filter(Boolean);
const normalizedTagsQuery = normalizedTags && normalizedTags.length > 0 ? normalizedTags.join(',') : undefined;
const [coursesResponse, tagsResponse] = await Promise.all([
classroomio.organization.courses.$get(
normalizedTagsQuery
? {
query: {
tags: normalizedTagsQuery
}
}
: {},
getApiHeaders(cookies, orgId)
),
classroomio.organization.tags.$get({}, getApiHeaders(cookies, orgId))
]);
const [coursesData, tagsData] = await Promise.all([coursesResponse.json(), tagsResponse.json()]);
const courses = coursesData.success ? coursesData.data : [];
const tagGroups = tagsData.success ? tagsData.data : [];
return {
courses: courses || [],
activeTags: normalizedTags ?? [],
tagGroups
};
};

Some files were not shown because too many files have changed in this diff Show More