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 <cursoragent@cursor.com>

* 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 <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

* 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 <best@classroomio.com>

* 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 <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

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

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

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

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* docs: add CSP ALLOWED_EXTERNAL_DOMAINS to self-hosting guides

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

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

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* 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 <cursoragent@cursor.com>

* 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 <rotimi-best@users.noreply.github.com>

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

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* 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 <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

* 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 <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
Co-authored-by: Abdulmuizz Hamzat <103611443+buildwithhamzat@users.noreply.github.com>
Co-authored-by: Adeyemi Adetunji <tvnji01@gmail.com>
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
@@ -0,0 +1,322 @@
import * as schema from '@db/schema';
import { and, desc, eq, inArray, or, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
/**
* Gets the last login time for a user
* @param userId User ID
* @returns Last login timestamp or undefined
*/
export async function getLastLogin(userId: string): Promise<string | null> {
try {
const [loginEvent] = await db
.select({
loggedInAt: schema.analyticsLoginEvents.loggedInAt
})
.from(schema.analyticsLoginEvents)
.where(eq(schema.analyticsLoginEvents.userId, userId))
.orderBy(desc(schema.analyticsLoginEvents.loggedInAt))
.limit(1);
return loginEvent?.loggedInAt;
} catch (error) {
console.error('getLastLoginAt error:', error);
return null;
}
}
/**
* Gets user exercise statistics for a course
* @param courseId Course ID
* @param userId User ID (profile ID)
* @returns Array of exercise stats with scores and completion status
*/
export async function getUserExercisesStats(courseId: string, userId: string) {
try {
const exercises = await db
.select({
id: schema.exercise.id,
title: schema.exercise.title,
lessonId: schema.exercise.lessonId,
lessonTitle: schema.lesson.title
})
.from(schema.exercise)
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.where(or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId)));
if (exercises.length === 0) {
return [];
}
const exerciseIds = exercises.map((e) => e.id);
// Get questions for these exercises with points
const questions = await db
.select({
exerciseId: schema.question.exerciseId,
points: schema.question.points
})
.from(schema.question)
.where(inArray(schema.question.exerciseId, exerciseIds));
// Get groupmember for the user in this course's group
const course = await db
.select({
groupId: schema.course.groupId
})
.from(schema.course)
.where(eq(schema.course.id, courseId))
.limit(1);
if (!course[0] || !course[0].groupId) {
return [];
}
const groupMember = await db
.select({
id: schema.groupmember.id
})
.from(schema.groupmember)
.where(and(eq(schema.groupmember.groupId, course[0].groupId), eq(schema.groupmember.profileId, userId)))
.limit(1);
if (!groupMember[0]) {
return [];
}
// Get submissions for these exercises by this user
const submissions = await db
.select({
id: schema.submission.id,
exerciseId: schema.submission.exerciseId,
total: schema.submission.total,
statusId: schema.submission.statusId
})
.from(schema.submission)
.where(
and(inArray(schema.submission.exerciseId, exerciseIds), eq(schema.submission.submittedBy, groupMember[0].id))
);
// Build exercise stats
const exerciseStats = exercises.map((exercise) => {
const exerciseQuestions = questions.filter((q) => q.exerciseId === exercise.id);
const totalPoints = exerciseQuestions.reduce((sum, q) => sum + (q.points || 0), 0);
const userSubmission = submissions.find((s) => s.exerciseId === exercise.id);
return {
id: exercise.id,
lessonId: exercise.lessonId,
lessonTitle: exercise.lessonTitle || '',
title: exercise.title,
status: userSubmission?.statusId,
score: userSubmission?.total || 0,
totalPoints,
isCompleted: !!userSubmission
};
});
return exerciseStats;
} catch (error) {
console.error('getUserExerciseStats error:', error);
return [];
}
}
/**
* Gets lessons with completion status for a user in a course
* @param courseId Course ID
* @param userId User ID (profile ID)
* @returns Array of lessons with completion status
*/
export async function getLessonsWithCompletion(courseId: string, userId: string) {
try {
const lessons = await db
.select({
id: schema.lesson.id,
title: schema.lesson.title,
createdAt: schema.lesson.createdAt
})
.from(schema.lesson)
.where(eq(schema.lesson.courseId, courseId));
const lessonIds = lessons.map((l) => l.id);
if (lessonIds.length === 0) {
return lessons.map((lesson) => ({
id: lesson.id,
title: lesson.title,
created_at: lesson.createdAt,
completed: false,
exerciseNo: 0
}));
}
// Get lesson completions
const completions = await db
.select({
lessonId: schema.lessonCompletion.lessonId
})
.from(schema.lessonCompletion)
.where(
and(
inArray(schema.lessonCompletion.lessonId, lessonIds),
eq(schema.lessonCompletion.profileId, userId),
eq(schema.lessonCompletion.isComplete, true)
)
);
// Get exercise counts per lesson
const exercises = await db
.select({
lessonId: schema.exercise.lessonId
})
.from(schema.exercise)
.where(inArray(schema.exercise.lessonId, lessonIds));
const completionSet = new Set(completions.map((c) => c.lessonId).filter((id): id is string => id !== null));
const exerciseCounts = new Map<string, number>();
exercises.forEach((e) => {
if (e.lessonId) {
exerciseCounts.set(e.lessonId, (exerciseCounts.get(e.lessonId) || 0) + 1);
}
});
return lessons.map((lesson) => ({
id: lesson.id,
title: lesson.title,
created_at: lesson.createdAt,
completed: completionSet.has(lesson.id),
exerciseNo: exerciseCounts.get(lesson.id) || 0
}));
} catch (error) {
console.error('getLessonsWithCompletion error:', error);
return [];
}
}
/**
* Gets profile course progress (lessons and exercises completed)
* @param courseId Course ID
* @param profileId Profile ID
* @returns Course progress data
*/
export async function getProfileCourseProgress(courseId: string, profileId: string) {
try {
// Get course group
const course = await db
.select({
groupId: schema.course.groupId
})
.from(schema.course)
.where(eq(schema.course.id, courseId))
.limit(1);
if (!course[0]) {
return {
lessons_count: 0,
lessons_completed: 0,
exercises_count: 0,
exercises_completed: 0
};
}
// Get groupmember for this user
if (!course[0].groupId) {
return {
lessons_count: 0,
lessons_completed: 0,
exercises_count: 0,
exercises_completed: 0
};
}
const groupMember = await db
.select({
id: schema.groupmember.id
})
.from(schema.groupmember)
.where(and(eq(schema.groupmember.groupId, course[0].groupId), eq(schema.groupmember.profileId, profileId)))
.limit(1);
if (!groupMember[0]) {
return {
lessons_count: 0,
lessons_completed: 0,
exercises_count: 0,
exercises_completed: 0
};
}
// Get lessons count
const lessonsResult = await db
.select({
count: sql<number>`COUNT(*)`.as('count')
})
.from(schema.lesson)
.where(eq(schema.lesson.courseId, courseId));
const lessonsCount = Number(lessonsResult[0]?.count || 0);
// Get lessons completed count
const lessonsCompletedResult = await db
.select({
count: sql<number>`COUNT(DISTINCT ${schema.lessonCompletion.lessonId})`.as('count')
})
.from(schema.lessonCompletion)
.innerJoin(schema.lesson, eq(schema.lessonCompletion.lessonId, schema.lesson.id))
.where(
and(
eq(schema.lesson.courseId, courseId),
eq(schema.lessonCompletion.profileId, profileId),
eq(schema.lessonCompletion.isComplete, true)
)
);
const lessonsCompleted = Number(lessonsCompletedResult[0]?.count || 0);
// Get exercises count
const exercisesResult = await db
.select({
count: sql<number>`COUNT(*)`.as('count')
})
.from(schema.exercise)
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.where(or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId)));
const exercisesCount = Number(exercisesResult[0]?.count || 0);
// Get exercises completed count
const exercisesCompletedResult = await db
.select({
count: sql<number>`COUNT(DISTINCT ${schema.submission.exerciseId})`.as('count')
})
.from(schema.submission)
.innerJoin(schema.exercise, eq(schema.submission.exerciseId, schema.exercise.id))
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.where(
and(
or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId)),
eq(schema.submission.submittedBy, groupMember[0].id)
)
);
const exercisesCompleted = Number(exercisesCompletedResult[0]?.count || 0);
return {
lessons_count: lessonsCount,
lessons_completed: lessonsCompleted,
exercises_count: exercisesCount,
exercises_completed: exercisesCompleted
};
} catch (error) {
console.error('getProfileCourseProgress error:', error);
return {
lessons_count: 0,
lessons_completed: 0,
exercises_count: 0,
exercises_completed: 0
};
}
}
@@ -0,0 +1 @@
export * from './analytics';
+486
View File
@@ -0,0 +1,486 @@
import * as schema from '@db/schema';
import { and, asc, count, desc, eq, inArray, ne, sql, type SQL, type InferSelectModel } from '@db/drizzle';
import { db, type DbOrTxClient } from '@db/drizzle';
import type { TAsset, TAssetUsage, TNewAsset, TNewAssetUsage } from '@db/types';
export interface ListAssetsOptions {
page?: number;
limit?: number;
kind?: string;
status?: string;
search?: string;
includeExternal?: boolean;
}
export interface ListAssetsResult {
items: TAsset[];
page: number;
limit: number;
total: number;
totalPages: number;
}
export interface AssetStorageSummary {
totalBytes: number;
internalBytes: number;
externalAssetCount: number;
bytesByKind: Record<string, number>;
}
export interface AssetDetachInput {
usageId?: string;
targetType?: string;
targetId?: string;
slotType?: string;
slotKey?: string | null;
position?: number | null;
}
function buildAssetWhereConditions(orgId: string, options: ListAssetsOptions): SQL[] {
const conditions: SQL[] = [eq(schema.asset.organizationId, orgId)];
if (options.kind) {
conditions.push(eq(schema.asset.kind, options.kind));
}
if (options.status) {
conditions.push(eq(schema.asset.status, options.status));
}
if (options.includeExternal === false) {
conditions.push(eq(schema.asset.isExternal, false));
}
if (options.search?.trim()) {
const search = `%${options.search.trim().toLowerCase()}%`;
conditions.push(
sql`(
lower(coalesce(${schema.asset.title}, '')) like ${search}
or lower(coalesce(${schema.asset.description}, '')) like ${search}
or lower(coalesce(${schema.asset.storageKey}, '')) like ${search}
)`
);
}
return conditions;
}
export async function createAsset(values: TNewAsset, dbClient: DbOrTxClient = db): Promise<TAsset> {
try {
const [created] = await dbClient.insert(schema.asset).values(values).returning();
if (!created) {
throw new Error('Failed to create asset');
}
return created;
} catch (error) {
console.error('createAsset error:', error);
throw new Error(`Failed to create asset: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function createOrGetAssetByStorageKey(values: TNewAsset, dbClient: DbOrTxClient = db): Promise<TAsset> {
try {
if (!values.storageKey) {
return createAsset(values, dbClient);
}
const [createdOrUpdated] = await dbClient
.insert(schema.asset)
.values(values)
.onConflictDoUpdate({
target: [schema.asset.organizationId, schema.asset.provider, schema.asset.storageKey],
set: {
sourceUrl: values.sourceUrl,
mimeType: values.mimeType,
byteSize: values.byteSize,
checksum: values.checksum,
title: values.title,
description: values.description,
thumbnailUrl: values.thumbnailUrl,
durationSeconds: values.durationSeconds,
aspectRatio: values.aspectRatio,
metadata: values.metadata,
updatedAt: new Date().toISOString()
}
})
.returning();
if (!createdOrUpdated) {
throw new Error('Failed to create or get asset');
}
return createdOrUpdated;
} catch (error) {
console.error('createOrGetAssetByStorageKey error:', error);
throw new Error(`Failed to create or get asset: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getAssetById(assetId: string, orgId?: string): Promise<TAsset | null> {
try {
const conditions = [eq(schema.asset.id, assetId)];
if (orgId) {
conditions.push(eq(schema.asset.organizationId, orgId));
}
const [item] = await db
.select()
.from(schema.asset)
.where(and(...conditions))
.limit(1);
return item || null;
} catch (error) {
console.error('getAssetById error:', error);
throw new Error(`Failed to get asset by id: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getAssetsByIds(assetIds: string[], orgId?: string): Promise<TAsset[]> {
try {
if (assetIds.length === 0) {
return [];
}
const conditions: SQL[] = [inArray(schema.asset.id, assetIds)];
if (orgId) {
conditions.push(eq(schema.asset.organizationId, orgId));
}
return await db
.select()
.from(schema.asset)
.where(and(...conditions));
} catch (error) {
console.error('getAssetsByIds error:', error);
throw new Error(`Failed to get assets by ids: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function listAssetsByOrg(orgId: string, options: ListAssetsOptions = {}): Promise<ListAssetsResult> {
try {
const page = options.page && options.page > 0 ? options.page : 1;
const limit = options.limit && options.limit > 0 ? Math.min(options.limit, 100) : 20;
const offset = (page - 1) * limit;
const conditions = buildAssetWhereConditions(orgId, options);
const [totalRow] = await db
.select({ count: count(schema.asset.id) })
.from(schema.asset)
.where(and(...conditions));
const total = Number(totalRow?.count ?? 0);
const items = await db
.select()
.from(schema.asset)
.where(and(...conditions))
.orderBy(desc(schema.asset.createdAt))
.limit(limit)
.offset(offset);
return {
items,
page,
limit,
total,
totalPages: Math.max(1, Math.ceil(total / limit))
};
} catch (error) {
console.error('listAssetsByOrg error:', error);
throw new Error(
`Failed to list assets by organization: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateAsset(
assetId: string,
orgId: string,
data: Partial<TAsset>,
dbClient: DbOrTxClient = db
): Promise<TAsset | null> {
try {
const [updated] = await dbClient
.update(schema.asset)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(and(eq(schema.asset.id, assetId), eq(schema.asset.organizationId, orgId)))
.returning();
return updated || null;
} catch (error) {
console.error('updateAsset error:', error);
throw new Error(`Failed to update asset: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function archiveAsset(
assetId: string,
orgId: string,
dbClient: DbOrTxClient = db
): Promise<TAsset | null> {
try {
const [updated] = await dbClient
.update(schema.asset)
.set({ status: 'archived', updatedAt: new Date().toISOString() })
.where(and(eq(schema.asset.id, assetId), eq(schema.asset.organizationId, orgId)))
.returning();
return updated || null;
} catch (error) {
console.error('archiveAsset error:', error);
throw new Error(`Failed to archive asset: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function deleteAsset(assetId: string, orgId: string, dbClient: DbOrTxClient = db): Promise<TAsset | null> {
try {
const [deleted] = await dbClient
.delete(schema.asset)
.where(and(eq(schema.asset.id, assetId), eq(schema.asset.organizationId, orgId)))
.returning();
return deleted || null;
} catch (error) {
console.error('deleteAsset error:', error);
throw new Error(`Failed to delete asset: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function createAssetUsage(values: TNewAssetUsage, dbClient: DbOrTxClient = db): Promise<TAssetUsage> {
try {
const slotType = values.slotType ?? 'lesson_video';
const [created] = await dbClient
.insert(schema.assetUsage)
.values({ ...values, slotType })
.onConflictDoNothing({
target: [
schema.assetUsage.assetId,
schema.assetUsage.targetType,
schema.assetUsage.targetId,
schema.assetUsage.slotType,
schema.assetUsage.slotKey,
schema.assetUsage.position
]
})
.returning();
if (created) {
return created;
}
const [existing] = await dbClient
.select()
.from(schema.assetUsage)
.where(
and(
eq(schema.assetUsage.assetId, values.assetId),
eq(schema.assetUsage.targetType, values.targetType),
eq(schema.assetUsage.targetId, values.targetId),
eq(schema.assetUsage.slotType, slotType),
values.slotKey == null
? sql`${schema.assetUsage.slotKey} is null`
: eq(schema.assetUsage.slotKey, values.slotKey),
values.position == null
? sql`${schema.assetUsage.position} is null`
: eq(schema.assetUsage.position, values.position)
)
)
.limit(1);
if (!existing) {
throw new Error('Failed to create or get asset usage');
}
return existing;
} catch (error) {
console.error('createAssetUsage error:', error);
throw new Error(`Failed to create asset usage: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function deleteAssetUsage(
orgId: string,
assetId: string,
input: AssetDetachInput,
dbClient: DbOrTxClient = db
): Promise<TAssetUsage | null> {
try {
const conditions: SQL[] = [eq(schema.assetUsage.organizationId, orgId), eq(schema.assetUsage.assetId, assetId)];
if (input.usageId) {
conditions.push(eq(schema.assetUsage.id, input.usageId));
} else {
if (!input.targetType || !input.targetId || !input.slotType) {
throw new Error('usageId or targetType/targetId/slotType is required');
}
conditions.push(eq(schema.assetUsage.targetType, input.targetType));
conditions.push(eq(schema.assetUsage.targetId, input.targetId));
conditions.push(eq(schema.assetUsage.slotType, input.slotType));
if (input.slotKey == null) {
conditions.push(sql`${schema.assetUsage.slotKey} is null`);
} else {
conditions.push(eq(schema.assetUsage.slotKey, input.slotKey));
}
if (input.position == null) {
conditions.push(sql`${schema.assetUsage.position} is null`);
} else {
conditions.push(eq(schema.assetUsage.position, input.position));
}
}
const [deleted] = await dbClient
.delete(schema.assetUsage)
.where(and(...conditions))
.returning();
return deleted || null;
} catch (error) {
console.error('deleteAssetUsage error:', error);
throw new Error(`Failed to delete asset usage: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function listAssetUsagesByAsset(assetId: string, orgId: string): Promise<TAssetUsage[]> {
try {
return await db
.select()
.from(schema.assetUsage)
.where(and(eq(schema.assetUsage.assetId, assetId), eq(schema.assetUsage.organizationId, orgId)))
.orderBy(asc(schema.assetUsage.targetType), asc(schema.assetUsage.slotType), asc(schema.assetUsage.createdAt));
} catch (error) {
console.error('listAssetUsagesByAsset error:', error);
throw new Error(`Failed to list asset usages: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function countAssetUsagesByAsset(assetId: string, orgId: string): Promise<number> {
try {
const [result] = await db
.select({ count: count(schema.assetUsage.id) })
.from(schema.assetUsage)
.where(and(eq(schema.assetUsage.assetId, assetId), eq(schema.assetUsage.organizationId, orgId)));
return Number(result?.count ?? 0);
} catch (error) {
console.error('countAssetUsagesByAsset error:', error);
throw new Error(`Failed to count asset usages: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function listAssetsForExport(
orgId: string,
options: { includeArchived?: boolean; includeExternal?: boolean } = {}
): Promise<Array<TAsset & { usageCount: number; usages: TAssetUsage[] }>> {
try {
const conditions: SQL[] = [eq(schema.asset.organizationId, orgId)];
if (!options.includeArchived) {
conditions.push(ne(schema.asset.status, 'archived'));
}
if (!options.includeExternal) {
conditions.push(eq(schema.asset.isExternal, false));
}
const assets = await db
.select()
.from(schema.asset)
.where(and(...conditions))
.orderBy(desc(schema.asset.createdAt));
if (!assets.length) {
return [];
}
const assetIds = assets.map((item) => item.id);
const usages = await db
.select()
.from(schema.assetUsage)
.where(and(eq(schema.assetUsage.organizationId, orgId), inArray(schema.assetUsage.assetId, assetIds)));
const usageByAsset = new Map<string, TAssetUsage[]>();
for (const usage of usages) {
const existing = usageByAsset.get(usage.assetId) || [];
existing.push(usage);
usageByAsset.set(usage.assetId, existing);
}
return assets.map((item) => {
const itemUsages = usageByAsset.get(item.id) || [];
return {
...item,
usageCount: itemUsages.length,
usages: itemUsages
};
});
} catch (error) {
console.error('listAssetsForExport error:', error);
throw new Error(`Failed to list assets for export: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getAssetStorageSummaryByOrg(
orgId: string,
options: { includeArchived?: boolean; includeExternal?: boolean } = {}
): Promise<AssetStorageSummary> {
try {
const conditions: SQL[] = [eq(schema.asset.organizationId, orgId)];
if (!options.includeArchived) {
conditions.push(ne(schema.asset.status, 'archived'));
}
if (!options.includeExternal) {
conditions.push(eq(schema.asset.isExternal, false));
}
const grouped = await db
.select({
kind: schema.asset.kind,
bytes: sql<number>`coalesce(sum(${schema.asset.byteSize}), 0)::bigint`.as('bytes')
})
.from(schema.asset)
.where(and(...conditions))
.groupBy(schema.asset.kind);
const [totalRow] = await db
.select({
totalBytes: sql<number>`coalesce(sum(${schema.asset.byteSize}), 0)::bigint`.as('total_bytes'),
internalBytes:
sql<number>`coalesce(sum(case when ${schema.asset.isExternal} = false then ${schema.asset.byteSize} else 0 end), 0)::bigint`.as(
'internal_bytes'
),
externalAssetCount:
sql<number>`coalesce(sum(case when ${schema.asset.isExternal} = true then 1 else 0 end), 0)::int`.as(
'external_asset_count'
)
})
.from(schema.asset)
.where(and(...conditions));
const bytesByKind: Record<string, number> = {};
for (const row of grouped) {
bytesByKind[row.kind || 'unknown'] = Number(row.bytes ?? 0);
}
return {
totalBytes: Number(totalRow?.totalBytes ?? 0),
internalBytes: Number(totalRow?.internalBytes ?? 0),
externalAssetCount: Number(totalRow?.externalAssetCount ?? 0),
bytesByKind
};
} catch (error) {
console.error('getAssetStorageSummaryByOrg error:', error);
throw new Error(`Failed to get asset storage summary: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export type AssetRow = InferSelectModel<typeof schema.asset>;
export type AssetUsageRow = InferSelectModel<typeof schema.assetUsage>;
+1
View File
@@ -0,0 +1 @@
export * from './assets';
@@ -0,0 +1,96 @@
import * as schema from '@db/schema';
import type { TGroupAttendance, TNewGroupAttendance } from '@db/types';
import { and, db, eq } from '@db/drizzle';
/**
* Gets attendance records by course ID
* @param courseId Course ID
* @param lessonId Optional lesson ID filter
* @returns Array of attendance records
*/
export async function getAttendanceByCourseId(courseId: string, lessonId?: string): Promise<TGroupAttendance[]> {
try {
if (lessonId) {
return db
.select()
.from(schema.groupAttendance)
.where(and(eq(schema.groupAttendance.courseId, courseId), eq(schema.groupAttendance.lessonId, lessonId)));
}
return db.select().from(schema.groupAttendance).where(eq(schema.groupAttendance.courseId, courseId));
} catch (error) {
console.error('getAttendanceByCourseId error:', error);
throw new Error(
`Failed to get attendance by course ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Gets attendance record for a specific student and lesson
* @param courseId Course ID
* @param lessonId Lesson ID
* @param studentId Student (group member) ID
* @returns Attendance record or null
*/
export async function getAttendanceByStudentAndLesson(
courseId: string,
lessonId: string,
studentId: string
): Promise<TGroupAttendance | null> {
try {
const [attendance] = await db
.select()
.from(schema.groupAttendance)
.where(
and(
eq(schema.groupAttendance.courseId, courseId),
eq(schema.groupAttendance.lessonId, lessonId),
eq(schema.groupAttendance.studentId, studentId)
)
)
.limit(1);
return attendance || null;
} catch (error) {
console.error('getAttendanceByStudentAndLesson error:', error);
throw new Error(`Failed to get attendance: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Creates or updates an attendance record
* @param data Attendance data
* @returns Created or updated attendance record
*/
export async function upsertAttendance(data: TNewGroupAttendance): Promise<TGroupAttendance> {
try {
// Check if record exists
const existing = await getAttendanceByStudentAndLesson(data.courseId!, data.lessonId, data.studentId!);
if (existing) {
// Update existing record
const [updated] = await db
.update(schema.groupAttendance)
.set({
isPresent: data.isPresent,
updatedAt: new Date().toISOString()
})
.where(eq(schema.groupAttendance.id, existing.id))
.returning();
if (!updated) {
throw new Error('Failed to update attendance');
}
return updated;
} else {
// Create new record
const [created] = await db.insert(schema.groupAttendance).values(data).returning();
if (!created) {
throw new Error('Failed to create attendance');
}
return created;
}
} catch (error) {
console.error('upsertAttendance error:', error);
throw new Error(`Failed to upsert attendance: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
@@ -0,0 +1 @@
export * from './attendance';
+6
View File
@@ -10,6 +10,12 @@ export const getProfileById = async (id: string) => {
return profile;
};
export const getProfileByEmail = async (email: string) => {
const [profile] = await db.select().from(schema.profile).where(eq(schema.profile.email, email)).limit(1);
return profile;
};
export const updateProfile = async (id: string, data: Partial<Omit<TProfile, 'id' | 'createdAt' | 'updatedAt'>>) => {
const [updatedProfile] = await db.update(schema.profile).set(data).where(eq(schema.profile.id, id)).returning();
+23 -14
View File
@@ -1,20 +1,29 @@
import { TCommunityAnswer, TCommunityQuestion, TNewCommunityQuestion } from '@db/types';
import {
communityAnswer,
communityQuestion,
course,
db,
desc,
eq,
inArray,
organization,
profile,
sql
} from '@db/drizzle';
import { communityAnswer, communityQuestion, course, db, desc, eq, inArray, profile, sql } from '@db/drizzle';
export async function getCommunityQuestions(courseIdsFilter: string[]) {
export type CommunityQuestionQueryResult = {
organizationId: TCommunityQuestion['organizationId'];
courseId: TCommunityQuestion['courseId'];
title: TCommunityQuestion['title'];
votes: TCommunityQuestion['votes'];
createdAt: TCommunityQuestion['createdAt'];
slug: TCommunityQuestion['slug'];
comments: number;
authorFullname: string | null;
courseTitle: string | null;
};
export async function getCommunityQuestions(
options: { courseIds: string[] } | { orgId: string }
): Promise<CommunityQuestionQueryResult[]> {
const commentsCount = sql<number>`COUNT(${communityAnswer.id})`;
// Build where condition based on options
const whereCondition =
'courseIds' in options
? inArray(communityQuestion.courseId, options.courseIds)
: eq(communityQuestion.organizationId, options.orgId);
return db
.select({
organizationId: communityQuestion.organizationId,
@@ -31,7 +40,7 @@ export async function getCommunityQuestions(courseIdsFilter: string[]) {
.innerJoin(course, eq(communityQuestion.courseId, course.id))
.leftJoin(communityAnswer, eq(communityAnswer.questionId, communityQuestion.id))
.leftJoin(profile, eq(profile.id, communityQuestion.authorProfileId))
.where(inArray(communityQuestion.courseId, courseIdsFilter))
.where(whereCondition)
.groupBy(
communityQuestion.organizationId,
communityQuestion.courseId,
+166
View File
@@ -0,0 +1,166 @@
import { sql } from 'drizzle-orm';
import { ContentType } from '@cio/utils/constants';
import { db, sql as drizzleSql } from '@db/drizzle';
export type CourseContentItemRow = {
id: string;
type: ContentType;
title: string | null;
order: number | null;
createdAt: string | null;
sectionId: string | null;
isUnlocked: boolean | null;
isComplete: boolean | null;
lessonAt: string | null;
callUrl: string | null;
hasNoteContent: boolean | null;
hasSlideContent: boolean | null;
videosCount: number | null;
documentsCount: number | null;
questionCount: number | null;
dueBy: string | null;
};
/**
* Fetches raw course content items (sections, lessons, exercises) for a course.
* @param courseId Course ID
* @param profileId Optional profile ID for completion state
*/
export async function getCourseContentItems(courseId: string, profileId?: string): Promise<CourseContentItemRow[]> {
try {
const lessonCompletionSql = profileId
? drizzleSql`COALESCE(
(
SELECT bool_or(is_complete)
FROM lesson_completion
WHERE lesson_id = lesson.id AND profile_id = ${profileId}
),
false
)`
: drizzleSql`false`;
const exerciseCompletionSql = profileId
? drizzleSql`COALESCE(
(
SELECT true
FROM submission
INNER JOIN groupmember ON groupmember.id = submission.submitted_by
WHERE submission.exercise_id = exercise.id AND groupmember.profile_id = ${profileId}
LIMIT 1
),
false
)`
: drizzleSql`false`;
const result = await db.execute(drizzleSql`
SELECT
id,
${ContentType.Section}::text AS type,
title,
"order",
created_at AS "createdAt",
NULL::uuid AS "sectionId",
NULL::boolean AS "isUnlocked",
NULL::boolean AS "isComplete",
NULL::timestamptz AS "lessonAt",
NULL::text AS "callUrl",
NULL::boolean AS "hasNoteContent",
NULL::boolean AS "hasSlideContent",
NULL::int AS "videosCount",
NULL::int AS "documentsCount",
NULL::int AS "questionCount",
NULL::timestamp AS "dueBy"
FROM course_section
WHERE course_id = ${courseId}
UNION ALL
SELECT
id,
${ContentType.Lesson}::text AS type,
title,
"order",
created_at AS "createdAt",
section_id AS "sectionId",
is_unlocked AS "isUnlocked",
${lessonCompletionSql} AS "isComplete",
lesson_at AS "lessonAt",
call_url AS "callUrl",
(
(
length(trim(COALESCE(note, ''))) > 0
)
OR EXISTS (
SELECT 1
FROM lesson_language
WHERE lesson_language.lesson_id = lesson.id
AND length(
trim(
regexp_replace(
regexp_replace(COALESCE(lesson_language.content, ''), '<[^>]*>', '', 'g'),
'&nbsp;|&#160;',
' ',
'g'
)
)
) > 0
)
) AS "hasNoteContent",
(
length(trim(COALESCE(slide_url, ''))) > 0
) AS "hasSlideContent",
COALESCE(jsonb_array_length(videos), 0) AS "videosCount",
COALESCE(jsonb_array_length(documents), 0) AS "documentsCount",
NULL::int AS "questionCount",
NULL::timestamp AS "dueBy"
FROM lesson
WHERE course_id = ${courseId}
UNION ALL
SELECT
exercise.id,
${ContentType.Exercise}::text AS type,
exercise.title,
exercise."order",
exercise.created_at AS "createdAt",
COALESCE(exercise.section_id, lesson.section_id) AS "sectionId",
exercise.is_unlocked AS "isUnlocked",
${exerciseCompletionSql} AS "isComplete",
NULL::timestamptz AS "lessonAt",
NULL::text AS "callUrl",
NULL::boolean AS "hasNoteContent",
NULL::boolean AS "hasSlideContent",
NULL::int AS "videosCount",
NULL::int AS "documentsCount",
(
SELECT COUNT(*)
FROM question
WHERE question.exercise_id = exercise.id
)::int AS "questionCount",
exercise.due_by AS "dueBy"
FROM exercise
LEFT JOIN lesson ON lesson.id = exercise.lesson_id
WHERE exercise.course_id = ${courseId} OR lesson.course_id = ${courseId}
`);
const rows = result.map((row) => row as CourseContentItemRow);
return rows.map((row) => ({
...row,
type: row.type as ContentType,
order: row.order === null ? null : Number(row.order),
hasNoteContent: row.hasNoteContent === null ? null : Boolean(row.hasNoteContent),
hasSlideContent: row.hasSlideContent === null ? null : Boolean(row.hasSlideContent),
videosCount: row.videosCount === null ? null : Number(row.videosCount),
documentsCount: row.documentsCount === null ? null : Number(row.documentsCount),
questionCount: row.questionCount === null ? null : Number(row.questionCount)
}));
} catch (error) {
console.error('getCourseContentItems error:', error);
throw new Error(
`Failed to get course content items for course "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
+733 -44
View File
@@ -1,55 +1,89 @@
import * as schema from '@db/schema';
import { and, eq, sql } from 'drizzle-orm';
import {
TCourse,
TCourseSection,
TGroup,
TGroupAttendance,
TGroupmember,
TNewCourse,
TNewCourseNewsfeed,
TNewCourseSection,
TProfile
} from '@db/types';
import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
import { TCourse, TNewCourse } from '@db/types';
import { db } from '@db/drizzle';
import { ROLE } from '@cio/utils/constants';
import { db, type DbOrTxClient } from '@db/drizzle';
import { getCourseContentItems, type CourseContentItemRow } from './content';
/**
* Gets courses by organization siteName
* @param siteName Organization site name
* @returns Array of courses with lesson counts and organization info
* Base course type - extends TCourse with lessonCount
* Used by public endpoint for landing pages
*/
export const getCoursesBySiteName = async (siteName: string): Promise<TCourse[]> => {
export interface TBaseCourse extends TCourse {
lessonCount: number;
}
/**
* Admin/Tutor course type - extends TBaseCourse with totalStudents
* Used for admin and tutor dashboard views
*/
export interface TAdminCourse extends TBaseCourse {
totalStudents: number;
}
/**
* Student course type - extends TBaseCourse with progress fields
* Used for student dashboard and enrolled courses views
* progressRate = lessons completed, exercisesCompleted = exercises submitted
*/
export interface TStudentCourse extends TBaseCourse {
progressRate: number;
exerciseCount: number;
exercisesCompleted: number;
}
/**
* Gets published courses by organization siteName
* @param siteName Organization site name
* @returns Array of published courses with lesson counts
*/
export const getPublishedCoursesBySiteName = async (siteName: string, courseIds?: string[]): Promise<TBaseCourse[]> => {
try {
if (courseIds && courseIds.length === 0) {
return [];
}
const conditions = [
eq(schema.organization.siteName, siteName),
eq(schema.course.status, 'ACTIVE'),
eq(schema.course.isPublished, true)
];
if (courseIds && courseIds.length > 0) {
conditions.push(inArray(schema.course.id, courseIds));
}
const result = await db
.select({
course: schema.course,
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName,
avatarUrl: schema.organization.avatarUrl
},
lessonCount: sql<number>`COUNT(${schema.lesson.id})`.as('lesson_count')
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId))
.where(
and(
eq(schema.organization.siteName, siteName),
eq(schema.course.status, 'ACTIVE'),
eq(schema.course.isPublished, true)
)
)
.groupBy(
schema.course.id,
schema.organization.id,
schema.organization.name,
schema.organization.siteName,
schema.organization.avatarUrl
);
.where(and(...conditions))
.groupBy(schema.course.id)
.orderBy(desc(schema.course.createdAt));
return result.map((row) => ({
...row.course,
lessons: [{ count: Number(row.lessonCount) }],
group: {
organization: row.organization
}
lessonCount: Number(row.lessonCount)
}));
} catch (error) {
console.error('getCoursesBySiteName error:', error);
throw new Error(
`Failed to get courses by site name "${siteName}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
@@ -97,6 +131,7 @@ export const getCoursesById = async (orgId: string): Promise<TCourse[]> => {
}
}));
} catch (error) {
console.error('getCoursesById error:', error);
throw new Error(
`Failed to get courses by org ID "${orgId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
@@ -134,8 +169,11 @@ export const getExercisesBySiteName = async (siteName: string) => {
exercise: schema.exercise
})
.from(schema.exercise)
.innerJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.innerJoin(schema.course, eq(schema.lesson.courseId, schema.course.id))
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.innerJoin(
schema.course,
or(eq(schema.exercise.courseId, schema.course.id), eq(schema.lesson.courseId, schema.course.id))
)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.where(eq(schema.organization.siteName, siteName))
@@ -152,13 +190,7 @@ export const getExercisesBySiteName = async (siteName: string) => {
export const getCoursesBySiteNameForSetup = async (siteName: string) => {
const result = await db
.select({
course: schema.course,
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName,
avatarUrl: schema.organization.avatarUrl
}
course: schema.course
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
@@ -167,26 +199,683 @@ export const getCoursesBySiteNameForSetup = async (siteName: string) => {
.limit(1);
return result.map((row) => ({
...row.course,
group: {
organization: row.organization
}
...row.course
}));
};
export async function getCourseById(courseId: string) {
try {
return await db.select().from(schema.course).where(eq(schema.course.id, courseId)).limit(1);
} catch (error) {
console.error('getCourseById error:', error);
throw new Error(
`Failed to get course by ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Gets a course by ID or slug with related data (group, attendance, content items).
* @param courseId Course ID (optional if slug provided)
* @param slug Course slug (optional if courseId provided)
* @param profileId Profile ID of the current user (optional)
* @returns Course with related data or null if not found
*/
export type CourseWithRelations = TCourse & {
group:
| (TGroup & {
members: (TGroupmember & {
profile: Pick<TProfile, 'id' | 'fullname' | 'username' | 'avatarUrl' | 'email'> | null;
})[];
})
| null;
attendance: TGroupAttendance[];
contentItems: CourseContentItemRow[];
/** Set when course has groupId (from join on group -> organization) */
org?: {
id: string;
name: string;
siteName: string | null;
theme: string | null;
} | null;
};
export async function getCourseWithRelations(
courseId?: string,
slug?: string,
profileId?: string
): Promise<CourseWithRelations | null> {
try {
if (!courseId && !slug) {
throw new Error('Either courseId or slug must be provided');
}
// Get course
const courseQuery = slug
? db
.select()
.from(schema.course)
.where(and(eq(schema.course.slug, slug), eq(schema.course.status, 'ACTIVE')))
: db
.select()
.from(schema.course)
.where(and(eq(schema.course.id, courseId!), eq(schema.course.status, 'ACTIVE')));
const courses = await courseQuery.limit(1);
if (courses.length === 0) {
return null;
}
const course = courses[0];
const finalCourseId = course.id;
// Type definitions for query results (group query joins organization for org context)
type GroupQueryRow = {
group: TGroup;
member: TGroupmember | null;
profile: Pick<TProfile, 'id' | 'fullname' | 'username' | 'avatarUrl' | 'email'> | null;
organization: {
id: string;
name: string;
siteName: string | null;
theme: string | null;
} | null;
};
// Fetch group data (and org via join) if groupId exists
const groupDataPromise: Promise<GroupQueryRow[]> = course.groupId
? db
.select({
group: schema.group,
member: schema.groupmember,
profile: {
id: schema.profile.id,
fullname: schema.profile.fullname,
username: schema.profile.username,
avatarUrl: schema.profile.avatarUrl,
email: schema.profile.email
},
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName,
theme: schema.organization.theme
}
})
.from(schema.group)
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.leftJoin(schema.groupmember, eq(schema.group.id, schema.groupmember.groupId))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(eq(schema.group.id, course.groupId))
: Promise.resolve([]);
// Fetch related data in parallel
const [groupData, contentItems, attendance] = await Promise.all([
groupDataPromise,
getCourseContentItems(finalCourseId, profileId),
// Get attendance (using courseId, not groupId)
db.select().from(schema.groupAttendance).where(eq(schema.groupAttendance.courseId, finalCourseId))
]);
// Transform group data
const groupMembers = groupData
.filter((row) => row.member)
.map((row) => ({
...row.member!,
profile: row.profile || null
}));
const group =
groupData.length > 0 && groupData[0].group
? {
...groupData[0].group,
members: groupMembers
}
: null;
const org =
groupData.length > 0 && groupData[0].organization
? {
id: groupData[0].organization.id,
name: groupData[0].organization.name,
siteName: groupData[0].organization.siteName ?? null,
theme: groupData[0].organization.theme ?? null
}
: null;
return {
...course,
group,
attendance,
contentItems,
...(org !== null && { org })
};
} catch (error) {
console.error('getCourseWithRelations error:', error);
throw new Error(`Failed to get course with relations: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function createCourse(newCourse: TNewCourse) {
try {
return await db.insert(schema.course).values(newCourse).returning();
} catch (error) {
console.error('createCourse error:', error);
throw new Error(`Failed to create course: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function createCourseNewsfeed(newsfeed: TNewCourseNewsfeed) {
try {
return await db.insert(schema.courseNewsfeed).values(newsfeed).returning();
} catch (error) {
console.error('createCourseNewsfeed error:', error);
throw new Error(`Failed to create course newsfeed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Updates a course by ID
* @param courseId Course ID
* @param data Partial course data to update
* @returns Updated course
*/
export async function updateCourse(courseId: string, data: Partial<TCourse>) {
try {
const [updated] = await db
.update(schema.course)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(schema.course.id, courseId))
.returning();
return updated;
} catch (error) {
console.error('updateCourse error:', error);
throw new Error(`Failed to update course: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Soft deletes a course by setting status to 'DELETED'
* @param courseId Course ID
* @returns Updated course
*/
export async function deleteCourse(courseId: string) {
try {
const [deleted] = await db
.update(schema.course)
.set({ status: 'DELETED', updatedAt: new Date().toISOString() })
.where(eq(schema.course.id, courseId))
.returning();
return deleted;
} catch (error) {
console.error('deleteCourse error:', error);
throw new Error(`Failed to delete course: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Gets course progress for a profile
* Recreates the logic from get_course_progress RPC function
* @param courseId Course ID
* @param profileId Profile ID
* @returns Course progress with counts of lessons, completed lessons, exercises, and completed exercises
*/
export async function getCourseProgress(
courseId: string,
profileId: string
): Promise<{
lessonsCount: number;
lessonsCompleted: number;
exercisesCount: number;
exercisesCompleted: number;
}> {
try {
// Get the groupmember ID for this profile in the course
const groupMemberResult = await db
.select({ id: schema.groupmember.id })
.from(schema.groupmember)
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
.where(and(eq(schema.course.id, courseId), eq(schema.groupmember.profileId, profileId)))
.limit(1);
if (groupMemberResult.length === 0) {
// User is not a member of this course
return {
lessonsCount: 0,
lessonsCompleted: 0,
exercisesCount: 0,
exercisesCompleted: 0
};
}
const groupMemberId = groupMemberResult[0].id;
// Get all lessons for the course
const lessons = await db
.select({ id: schema.lesson.id })
.from(schema.lesson)
.where(eq(schema.lesson.courseId, courseId));
const lessonsCount = lessons.length;
// Get completed lessons (lesson_completion where is_complete = true and profile_id matches)
const completedLessons = await db
.select({ id: schema.lessonCompletion.id })
.from(schema.lessonCompletion)
.innerJoin(schema.lesson, eq(schema.lessonCompletion.lessonId, schema.lesson.id))
.where(
and(
eq(schema.lesson.courseId, courseId),
eq(schema.lessonCompletion.profileId, profileId),
eq(schema.lessonCompletion.isComplete, true)
)
);
const lessonsCompleted = completedLessons.length;
// Get all exercises for the course (legacy lesson_id support)
const exercises = await db
.select({ id: schema.exercise.id })
.from(schema.exercise)
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.where(or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId)));
const exercisesCount = exercises.length;
// Get completed exercises (submissions where submitted_by = groupmember.id)
const completedExercises = await db
.select({ id: schema.submission.id })
.from(schema.submission)
.innerJoin(schema.exercise, eq(schema.submission.exerciseId, schema.exercise.id))
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.where(
and(
or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId)),
eq(schema.submission.submittedBy, groupMemberId)
)
);
const exercisesCompleted = completedExercises.length;
return {
lessonsCount,
lessonsCompleted,
exercisesCount,
exercisesCompleted
};
} catch (error) {
console.error('getCourseProgress error:', error);
throw new Error(`Failed to get course progress: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
interface GetOrgCoursesOptions {
/** Organization ID (required) */
orgId: string;
/** Profile ID - if provided, filters to courses where user is a member */
profileId?: string;
/** Optional course IDs filter */
courseIds?: string[];
}
/**
* Gets courses for an organization with optional member filtering
* Returns courses with lesson_count and total_students
* @param options.orgId Organization ID
* @param options.profileId Optional profile ID to filter by membership
* @returns Array of courses with admin-level data
*/
export const getOrgCourses = async ({ orgId, profileId, courseIds }: GetOrgCoursesOptions): Promise<TAdminCourse[]> => {
try {
if (courseIds && courseIds.length === 0) {
return [];
}
const baseQuery = db
.select({
course: schema.course,
lessonCount: sql<number>`COUNT(DISTINCT ${schema.lesson.id})`.as('lesson_count'),
studentCount: sql<number>`(SELECT COUNT(*)::bigint
FROM ${schema.groupmember} as gm
WHERE gm.group_id = ${schema.course.groupId}
AND gm.role_id = ${ROLE.STUDENT}
)`.as('total_students')
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId));
// Build conditions
const conditions = [eq(schema.group.organizationId, orgId), eq(schema.course.status, 'ACTIVE')];
if (courseIds && courseIds.length > 0) {
conditions.push(inArray(schema.course.id, courseIds));
}
// If profileId provided, join groupmember and filter by membership
const query = profileId
? baseQuery
.innerJoin(schema.groupmember, eq(schema.group.id, schema.groupmember.groupId))
.where(and(...conditions, eq(schema.groupmember.profileId, profileId)))
: baseQuery.where(and(...conditions));
const result = await query.groupBy(schema.course.id).orderBy(desc(schema.course.createdAt));
return result.map((row) => ({
...row.course,
lessonCount: Number(row.lessonCount),
totalStudents: Number(row.studentCount)
}));
} catch (error) {
console.error('getOrgCourses error:', error);
throw new Error(`Failed to get org courses: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
interface GetEnrolledCoursesOptions {
/** Organization ID (required) */
orgId: string;
/** Student's profile ID (required) */
profileId: string;
}
/**
* Gets enrolled courses for a student in an organization
* Returns courses where the student is enrolled with lesson_count and progress_rate
* @param options.orgId Organization ID
* @param options.profileId Student's profile ID
* @returns Array of courses with student-level data including progress
*/
export const getEnrolledCourses = async ({
orgId,
profileId
}: GetEnrolledCoursesOptions): Promise<TStudentCourse[]> => {
try {
const result = await db
.select({
course: schema.course,
lessonCount: sql<number>`COUNT(DISTINCT ${schema.lesson.id})`.as('lesson_count'),
progressRate: sql<number>`(
SELECT COUNT(*)::bigint
FROM ${schema.lessonCompletion} as lc
JOIN ${schema.lesson} as l ON l.id = lc.lesson_id
WHERE l.course_id = ${schema.course.id}
AND lc.is_complete = true
AND lc.profile_id = ${sql.raw(`'${profileId}'::uuid`)}
)`.as('progress_rate'),
exerciseCount: sql<number>`(
SELECT COUNT(*)::bigint
FROM ${schema.exercise} as ex
LEFT JOIN ${schema.lesson} as el ON el.id = ex.lesson_id
WHERE ex.course_id = ${schema.course.id}
OR el.course_id = ${schema.course.id}
)`.as('exercise_count'),
exercisesCompleted: sql<number>`(
SELECT COUNT(DISTINCT s.exercise_id)::bigint
FROM ${schema.submission} as s
JOIN ${schema.exercise} as ex ON ex.id = s.exercise_id
LEFT JOIN ${schema.lesson} as el ON el.id = ex.lesson_id
WHERE (ex.course_id = ${schema.course.id} OR el.course_id = ${schema.course.id})
AND s.submitted_by = ${schema.groupmember.id}
)`.as('exercises_completed')
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.groupmember, eq(schema.group.id, schema.groupmember.groupId))
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId))
.where(
and(
eq(schema.group.organizationId, orgId),
eq(schema.course.status, 'ACTIVE'),
eq(schema.course.isPublished, true),
eq(schema.groupmember.profileId, profileId)
)
)
.groupBy(schema.course.id, schema.groupmember.id)
.orderBy(desc(schema.course.createdAt));
return result.map((row) => ({
...row.course,
lessonCount: Number(row.lessonCount),
progressRate: Number(row.progressRate),
exerciseCount: Number(row.exerciseCount),
exercisesCompleted: Number(row.exercisesCompleted)
}));
} catch (error) {
console.error('getEnrolledCourses error:', error);
throw new Error(`Failed to get enrolled courses: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
interface GetExploreCoursesOptions {
orgId: string;
profileId: string;
}
/**
* Gets recommended courses (published courses user isn't enrolled in) for an organization
*
* @param options.orgId Organization ID
* @param options.profileId Profile ID to exclude enrolled courses
* @returns Array of courses with explore-level data
*/
export const getExploreCourses = async ({ orgId, profileId }: GetExploreCoursesOptions): Promise<TBaseCourse[]> => {
try {
const result = await db
.select({
course: schema.course,
lessonCount: sql<number>`COUNT(DISTINCT ${schema.lesson.id})`.as('total_lessons')
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId))
.leftJoin(
schema.groupmember,
and(eq(schema.groupmember.groupId, schema.course.groupId), eq(schema.groupmember.profileId, profileId))
)
.where(
and(
eq(schema.group.organizationId, orgId),
eq(schema.course.status, 'ACTIVE'),
eq(schema.course.isPublished, true),
isNull(schema.groupmember.id)
)
)
.groupBy(schema.course.id, schema.group.id)
.orderBy(desc(schema.course.createdAt));
return result.map((row) => ({
...row.course,
lessonCount: Number(row.lessonCount)
}));
} catch (error) {
console.error('getExploreCourses error:', error);
throw new Error(`Failed to get explore courses: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
/**
* Gets course with organization data (title, org name, org siteName, groupId)
* @param courseId Course ID
* @returns Course and organization data or null if not found
*/
export async function getCourseWithOrgData(courseId: string): Promise<{
courseTitle: string | null;
orgName: string | null;
orgSiteName: string | null;
groupId: string | null;
} | null> {
try {
const result = await db
.select({
courseTitle: schema.course.title,
orgName: schema.organization.name,
orgSiteName: schema.organization.siteName,
groupId: schema.course.groupId
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.where(eq(schema.course.id, courseId))
.limit(1);
if (result.length === 0) {
return null;
}
return result[0];
} catch (error) {
console.error('getCourseWithOrgData error:', error);
throw new Error(
`Failed to get course with org data "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Gets organization name by course ID
* @param courseId Course ID
* @returns Organization name or null if not found
*/
export async function getOrganizationByCourseId(courseId: string): Promise<{ orgName: string | null } | null> {
try {
const result = await db
.select({
orgName: schema.organization.name
})
.from(schema.organization)
.innerJoin(schema.group, eq(schema.group.organizationId, schema.organization.id))
.innerJoin(schema.course, eq(schema.course.groupId, schema.group.id))
.where(eq(schema.course.id, courseId))
.limit(1);
if (result.length === 0) {
return null;
}
return result[0];
} catch (error) {
console.error('getOrganizationByCourseId error:', error);
throw new Error(
`Failed to get organization by course ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Updates all lessons in a course to use a specific section
* @param courseId Course ID
* @param sectionId Section ID
* @param tx Optional transaction context
* @returns Number of updated lessons
*/
export async function updateLessonsSectionId(
courseId: string,
sectionId: string,
tx?: Parameters<Parameters<typeof db.transaction>[0]>[0]
): Promise<number> {
try {
const dbInstance = tx || db;
const updated = await dbInstance
.update(schema.lesson)
.set({ sectionId })
.where(eq(schema.lesson.courseId, courseId))
.returning();
return updated.length;
} catch (error) {
console.error('updateLessonsSectionId error:', error);
throw new Error(
`Failed to update lessons section ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateUngroupedLessonsSectionId(
courseId: string,
sectionId: string,
tx?: Parameters<Parameters<typeof db.transaction>[0]>[0]
): Promise<number> {
try {
const dbInstance = tx || db;
const updated = await dbInstance
.update(schema.lesson)
.set({ sectionId })
.where(and(eq(schema.lesson.courseId, courseId), isNull(schema.lesson.sectionId)))
.returning();
return updated.length;
} catch (error) {
console.error('updateUngroupedLessonsSectionId error:', error);
throw new Error(
`Failed to update ungrouped lessons section ID "${courseId}": ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
export async function getCourseSectionsByCourseId(courseId: string) {
try {
return db.select().from(schema.courseSection).where(eq(schema.courseSection.courseId, courseId));
} catch (error) {
console.error('getCourseSectionsByCourseId error:', error);
throw new Error(
`Failed to get sections by course ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getCourseSectionById(sectionId: string): Promise<TCourseSection | null> {
try {
const [section] = await db
.select()
.from(schema.courseSection)
.where(eq(schema.courseSection.id, sectionId))
.limit(1);
return section || null;
} catch (error) {
console.error('getCourseSectionById error:', error);
throw new Error(
`Failed to get course section by ID "${sectionId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createCourseSections(values: TNewCourseSection[], dbClient: DbOrTxClient = db) {
try {
return dbClient.insert(schema.courseSection).values(values).returning();
} catch (error) {
console.error('createCourseSections error:', error);
throw new Error(`Failed to create course sections: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function updateCourseSection(sectionId: string, data: Partial<TCourseSection>) {
try {
const [updated] = await db
.update(schema.courseSection)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(schema.courseSection.id, sectionId))
.returning();
return updated || null;
} catch (error) {
console.error('updateCourseSection error:', error);
throw new Error(
`Failed to update course section "${sectionId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function deleteCourseSection(sectionId: string, dbClient: DbOrTxClient = db) {
try {
const [deleted] = await dbClient
.delete(schema.courseSection)
.where(eq(schema.courseSection.id, sectionId))
.returning();
return deleted || null;
} catch (error) {
console.error('deleteCourseSection error:', error);
throw new Error(
`Failed to delete course section "${sectionId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
+4
View File
@@ -1 +1,5 @@
export * from './course';
export * from './course';
export * from './content';
export * from './people';
export * from './invite';
+335
View File
@@ -0,0 +1,335 @@
import type { TCourseInvite, TCourseInviteAudit, TNewCourseInvite, TNewCourseInviteAudit } from '@db/types';
import * as schema from '@db/schema';
import { and, desc, eq, gte, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
export async function createCourseInvite(values: TNewCourseInvite): Promise<TCourseInvite> {
try {
const [created] = await db.insert(schema.courseInvite).values(values).returning();
if (!created) {
throw new Error('Failed to create course invite');
}
return created;
} catch (error) {
console.error('createCourseInvite error:', error);
throw new Error(`Failed to create course invite: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function createCourseInviteAudit(values: TNewCourseInviteAudit): Promise<TCourseInviteAudit> {
try {
const [created] = await db.insert(schema.courseInviteAudit).values(values).returning();
if (!created) {
throw new Error('Failed to create course invite audit event');
}
return created;
} catch (error) {
console.error('createCourseInviteAudit error:', error);
throw new Error(
`Failed to create course invite audit event: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getCourseInviteById(courseId: string, inviteId: string): Promise<TCourseInvite | null> {
try {
const [invite] = await db
.select()
.from(schema.courseInvite)
.where(and(eq(schema.courseInvite.id, inviteId), eq(schema.courseInvite.courseId, courseId)))
.limit(1);
return invite || null;
} catch (error) {
console.error('getCourseInviteById error:', error);
throw new Error(`Failed to get course invite: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export type TCourseInviteListItem = {
id: string;
courseId: string;
roleId: number;
expiresAt: string;
maxUses: number;
usedCount: number;
isRevoked: boolean;
allowedEmails: string[] | null;
allowedDomains: string[] | null;
createdAt: string;
updatedAt: string;
lastUsedAt: string | null;
revokedAt: string | null;
revokedByProfileId: string | null;
createdBy: {
id: string;
fullname: string | null;
email: string | null;
} | null;
};
export type TCourseInviteAuditItem = {
id: string;
inviteId: string;
courseId: string;
eventType: (typeof schema.courseInviteEventType.enumValues)[number];
targetEmail: string | null;
ipAddress: string | null;
userAgent: string | null;
metadata: Record<string, unknown>;
createdAt: string;
actor: {
id: string;
fullname: string | null;
email: string | null;
} | null;
};
export type TCourseInviteAuditStatsRow = {
inviteId: string;
eventType: (typeof schema.courseInviteEventType.enumValues)[number];
count: number;
lastAt: string | null;
};
export async function listCourseInvites(courseId: string): Promise<TCourseInviteListItem[]> {
try {
const result = await db
.select({
invite: schema.courseInvite,
creator: {
id: schema.profile.id,
fullname: schema.profile.fullname,
email: schema.profile.email
}
})
.from(schema.courseInvite)
.leftJoin(schema.profile, eq(schema.courseInvite.createdByProfileId, schema.profile.id))
.where(eq(schema.courseInvite.courseId, courseId))
.orderBy(desc(schema.courseInvite.createdAt));
return result.map((row) => {
const creator = row.creator?.id
? {
id: row.creator.id,
fullname: row.creator.fullname,
email: row.creator.email
}
: null;
return {
id: row.invite.id,
courseId: row.invite.courseId,
roleId: row.invite.roleId,
expiresAt: row.invite.expiresAt,
maxUses: row.invite.maxUses,
usedCount: row.invite.usedCount,
isRevoked: row.invite.isRevoked,
allowedEmails: row.invite.allowedEmails,
allowedDomains: row.invite.allowedDomains,
createdAt: row.invite.createdAt,
updatedAt: row.invite.updatedAt,
lastUsedAt: row.invite.lastUsedAt || null,
revokedAt: row.invite.revokedAt || null,
revokedByProfileId: row.invite.revokedByProfileId || null,
createdBy: creator
};
});
} catch (error) {
console.error('listCourseInvites error:', error);
throw new Error(`Failed to list course invites: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function listCourseInviteAudit(
courseId: string,
inviteId: string,
limit = 100
): Promise<TCourseInviteAuditItem[]> {
try {
const result = await db
.select({
audit: schema.courseInviteAudit,
actor: {
id: schema.profile.id,
fullname: schema.profile.fullname,
email: schema.profile.email
}
})
.from(schema.courseInviteAudit)
.leftJoin(schema.profile, eq(schema.courseInviteAudit.actorProfileId, schema.profile.id))
.where(and(eq(schema.courseInviteAudit.courseId, courseId), eq(schema.courseInviteAudit.inviteId, inviteId)))
.orderBy(desc(schema.courseInviteAudit.createdAt))
.limit(limit);
return result.map((row) => ({
id: row.audit.id,
inviteId: row.audit.inviteId,
courseId: row.audit.courseId,
eventType: row.audit.eventType,
targetEmail: row.audit.targetEmail || null,
ipAddress: row.audit.ipAddress || null,
userAgent: row.audit.userAgent || null,
metadata: (row.audit.metadata as Record<string, unknown>) || {},
createdAt: row.audit.createdAt,
actor: row.actor?.id
? {
id: row.actor.id,
fullname: row.actor.fullname,
email: row.actor.email
}
: null
}));
} catch (error) {
console.error('listCourseInviteAudit error:', error);
throw new Error(`Failed to list invite audit events: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function listCourseInviteAuditStats(courseId: string): Promise<TCourseInviteAuditStatsRow[]> {
try {
const rows = await db
.select({
inviteId: schema.courseInviteAudit.inviteId,
eventType: schema.courseInviteAudit.eventType,
count: sql<number>`count(*)::int`.as('count'),
lastAt: sql<string | null>`max(${schema.courseInviteAudit.createdAt})`.as('last_at')
})
.from(schema.courseInviteAudit)
.where(eq(schema.courseInviteAudit.courseId, courseId))
.groupBy(schema.courseInviteAudit.inviteId, schema.courseInviteAudit.eventType);
return rows;
} catch (error) {
console.error('listCourseInviteAuditStats error:', error);
throw new Error(`Failed to list invite audit stats: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function countInviteDistinctPreviewIps(inviteId: string, sinceIsoDate: string): Promise<number> {
try {
const [row] = await db
.select({
count: sql<number>`count(distinct ${schema.courseInviteAudit.ipAddress})::int`.as('count')
})
.from(schema.courseInviteAudit)
.where(
and(
eq(schema.courseInviteAudit.inviteId, inviteId),
eq(schema.courseInviteAudit.eventType, 'PREVIEWED'),
gte(schema.courseInviteAudit.createdAt, sinceIsoDate),
sql`${schema.courseInviteAudit.ipAddress} is not null`
)
);
return row?.count ?? 0;
} catch (error) {
console.error('countInviteDistinctPreviewIps error:', error);
throw new Error(`Failed to count invite IP diversity: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function revokeCourseInvite(
courseId: string,
inviteId: string,
revokedByProfileId: string
): Promise<TCourseInvite | null> {
try {
const [updated] = await db
.update(schema.courseInvite)
.set({
isRevoked: true,
revokedAt: new Date().toISOString(),
revokedByProfileId,
updatedAt: new Date().toISOString()
})
.where(and(eq(schema.courseInvite.id, inviteId), eq(schema.courseInvite.courseId, courseId)))
.returning();
return updated || null;
} catch (error) {
console.error('revokeCourseInvite error:', error);
throw new Error(`Failed to revoke course invite: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export type TCourseInviteTokenData = {
invite: TCourseInvite;
course: {
id: string;
title: string;
description: string;
status: string;
isPublished: boolean;
metadata: Record<string, unknown> | null;
groupId: string;
slug: string | null;
cost: number;
};
organization: {
id: string;
name: string;
siteName: string;
theme: string | null;
};
};
export async function getCourseInviteByTokenHash(tokenHash: string): Promise<TCourseInviteTokenData | null> {
try {
const [result] = await db
.select({
invite: schema.courseInvite,
course: {
id: schema.course.id,
title: schema.course.title,
description: schema.course.description,
status: schema.course.status,
isPublished: schema.course.isPublished,
metadata: schema.course.metadata,
groupId: schema.group.id,
slug: schema.course.slug,
cost: schema.course.cost
},
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName,
theme: schema.organization.theme
}
})
.from(schema.courseInvite)
.innerJoin(schema.course, eq(schema.courseInvite.courseId, schema.course.id))
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.where(eq(schema.courseInvite.tokenHash, tokenHash))
.limit(1);
if (!result) {
return null;
}
return {
...result,
course: {
...result.course,
isPublished: !!result.course.isPublished,
cost: Number(result.course.cost ?? 0)
},
organization: {
...result.organization,
siteName: result.organization.siteName ?? '',
theme: result.organization.theme ?? null
}
};
} catch (error) {
console.error('getCourseInviteByTokenHash error:', error);
throw new Error(
`Failed to get course invite by token: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
+309
View File
@@ -0,0 +1,309 @@
import * as schema from '@db/schema';
import { TGroupmember, TNewGroupmember } from '@db/types';
import { and, eq, or } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
/**
* Gets all course members (people) for a course
* Returns members with their profile information
* @param courseId Course ID
* @returns Array of course members with profile data
*/
export async function getCourseMembers(courseId: string): Promise<
Array<
TGroupmember & {
profile: {
id: string;
fullname: string | null;
username: string | null;
avatarUrl: string | null;
email: string | null;
} | null;
}
>
> {
try {
const result = await db
.select({
member: schema.groupmember,
profile: {
id: schema.profile.id,
fullname: schema.profile.fullname,
username: schema.profile.username,
avatarUrl: schema.profile.avatarUrl,
email: schema.profile.email
}
})
.from(schema.groupmember)
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(eq(schema.course.id, courseId));
return result.map((row) => ({
...row.member,
profile: row.profile || null
}));
} catch (error) {
console.error('getCourseMembers error:', error);
throw new Error(`Failed to get course members: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Gets a course member by ID
* @param courseId Course ID
* @param memberId Member ID
* @returns Course member with profile data or null if not found
*/
export async function getCourseMember(
courseId: string,
memberId: string
): Promise<
| (TGroupmember & {
profile: {
id: string;
fullname: string | null;
username: string | null;
avatarUrl: string | null;
email: string | null;
} | null;
})
| null
> {
try {
const result = await db
.select({
member: schema.groupmember,
profile: {
id: schema.profile.id,
fullname: schema.profile.fullname,
username: schema.profile.username,
avatarUrl: schema.profile.avatarUrl,
email: schema.profile.email
}
})
.from(schema.groupmember)
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(and(eq(schema.course.id, courseId), eq(schema.groupmember.id, memberId)))
.limit(1);
if (result.length === 0) {
return null;
}
return {
...result[0].member,
profile: result[0].profile || null
};
} catch (error) {
console.error('getCourseMember error:', error);
throw new Error(`Failed to get course member: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Gets the group ID for a course
* @param courseId Course ID
* @returns Group ID or null if not found
*/
export async function getCourseGroupId(courseId: string): Promise<string | null> {
try {
const result = await db
.select({ groupId: schema.course.groupId })
.from(schema.course)
.where(eq(schema.course.id, courseId))
.limit(1);
return result.length > 0 ? result[0].groupId : null;
} catch (error) {
console.error('getCourseGroupId error:', error);
throw new Error(`Failed to get course group ID: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Gets teachers (ADMIN or TUTOR role) for a course
* @param options.courseId Course ID (optional if groupId provided)
* @param options.groupId Group ID (optional if courseId provided)
* @param options.limit Optional limit (default: no limit, returns all)
* @returns Array of teachers with profile data
*/
type TeacherProfile = {
id: string;
email: string | null;
fullname: string | null;
username: string | null;
avatarUrl: string | null;
};
export async function getCourseTeachers(options: {
courseId?: string;
groupId?: string;
limit?: number;
}): Promise<Array<TeacherProfile>> {
const { courseId, groupId, limit } = options;
if (!courseId && !groupId) {
throw new Error('Either courseId or groupId must be provided');
}
const profileSelect = {
id: schema.profile.id,
email: schema.profile.email,
fullname: schema.profile.fullname,
username: schema.profile.username,
avatarUrl: schema.profile.avatarUrl
};
const isTeacherRole = or(eq(schema.groupmember.roleId, ROLE.ADMIN), eq(schema.groupmember.roleId, ROLE.TUTOR));
const baseQuery = db
.select(profileSelect)
.from(schema.groupmember)
.innerJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id));
const query = courseId
? baseQuery
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
.where(and(eq(schema.course.id, courseId), isTeacherRole))
: baseQuery.where(and(eq(schema.groupmember.groupId, groupId!), isTeacherRole));
const result = await (limit ? query.limit(limit) : query);
return result;
}
/**
* Adds a course member (person) to a course
* @param courseId Course ID
* @param memberData Member data (profileId, roleId, email)
* @returns Created member
*/
export async function addCourseMember(
courseId: string,
memberData: { profileId?: string; roleId: number; email?: string }
): Promise<TGroupmember> {
try {
const groupId = await getCourseGroupId(courseId);
if (!groupId) {
throw new Error('Course group not found');
}
const [newMember] = await db
.insert(schema.groupmember)
.values({
groupId,
profileId: memberData.profileId,
roleId: memberData.roleId,
email: memberData.email
})
.returning();
if (!newMember) {
throw new Error('Failed to create course member');
}
return newMember;
} catch (error) {
console.error('addCourseMember error:', error);
throw new Error(`Failed to add course member: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Updates a course member
* @param courseId Course ID
* @param memberId Member ID
* @param data Partial member data to update
* @returns Updated member
*/
export async function updateCourseMember(
courseId: string,
memberId: string,
data: Partial<TGroupmember>
): Promise<TGroupmember | null> {
try {
// Verify member belongs to course
const member = await getCourseMember(courseId, memberId);
if (!member) {
return null;
}
const [updated] = await db
.update(schema.groupmember)
.set(data)
.where(eq(schema.groupmember.id, memberId))
.returning();
return updated || null;
} catch (error) {
console.error('updateCourseMember error:', error);
throw new Error(`Failed to update course member: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Deletes a course member
* @param courseId Course ID
* @param memberId Member ID
* @returns Deleted member or null if not found
*/
export async function deleteCourseMember(courseId: string, memberId: string): Promise<TGroupmember | null> {
try {
// Verify member belongs to course
const member = await getCourseMember(courseId, memberId);
if (!member) {
return null;
}
const [deleted] = await db.delete(schema.groupmember).where(eq(schema.groupmember.id, memberId)).returning();
return deleted || null;
} catch (error) {
console.error('deleteCourseMember error:', error);
throw new Error(`Failed to delete course member: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Gets profile data by group member ID
* @param groupMemberId Group member ID
* @returns Profile data or null if not found
*/
export async function getProfileByGroupMemberId(groupMemberId: string): Promise<{
id: string;
fullname: string | null;
username: string | null;
avatarUrl: string | null;
email: string | null;
} | null> {
try {
const result = await db
.select({
id: schema.profile.id,
fullname: schema.profile.fullname,
username: schema.profile.username,
avatarUrl: schema.profile.avatarUrl,
email: schema.profile.email
})
.from(schema.groupmember)
.innerJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(eq(schema.groupmember.id, groupMemberId))
.limit(1);
if (result.length === 0) {
return null;
}
return result[0];
} catch (error) {
console.error('getProfileByGroupMemberId error:', error);
throw new Error(
`Failed to get profile by group member ID "${groupMemberId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
+474 -16
View File
@@ -1,62 +1,520 @@
import { db, eq, inArray } from '@db/drizzle';
import { TNewExercise, TNewQuestion, TNewOption, TExercise } from '@db/types';
import * as schema from '@db/schema';
export async function createExercises(values: TNewExercise[]) {
import type {
TExercise,
TExercise as TExerciseType,
TNewExercise,
TNewOption,
TNewQuestion,
TQuestionType
} from '@db/types';
import { and, asc, db, eq, inArray, isNull, or, sql } from '@db/drizzle';
import type { DbOrTxClient } from '@db/drizzle';
export async function createExercises(values: TNewExercise[], dbClient: DbOrTxClient = db) {
try {
return db.insert(schema.exercise).values(values).returning();
return dbClient.insert(schema.exercise).values(values).returning();
} catch (error) {
console.error('createExercises error:', error);
throw new Error(`Failed to create exercises: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getExercisesByLessonIds(lessonIds: string[]): Promise<TExercise[]> {
export async function getExerciseById(exerciseId: string, dbClient: DbOrTxClient = db): Promise<TExercise | null> {
try {
const [exercise] = await dbClient.select().from(schema.exercise).where(eq(schema.exercise.id, exerciseId)).limit(1);
return exercise || null;
} catch (error) {
console.error('getExerciseById error:', error);
throw new Error(
`Failed to get exercise by ID "${exerciseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Checks if a profile has completed an exercise (submission exists)
*/
export async function getExerciseCompletionByProfile(
exerciseId: string,
profileId: string,
dbClient: DbOrTxClient = db
): Promise<boolean> {
try {
const result = await dbClient
.select({ exerciseId: schema.submission.exerciseId })
.from(schema.submission)
.innerJoin(schema.groupmember, eq(schema.submission.submittedBy, schema.groupmember.id))
.where(and(eq(schema.submission.exerciseId, exerciseId), eq(schema.groupmember.profileId, profileId)))
.limit(1);
return result.length > 0;
} catch (error) {
console.error('getExerciseCompletionByProfile error:', error);
throw new Error(
`Failed to get exercise completion for profile "${profileId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Gets completed exercise IDs for a profile (submission exists)
*/
export async function getExerciseCompletionsByProfile(
exerciseIds: string[],
profileId: string,
dbClient: DbOrTxClient = db
): Promise<string[]> {
if (exerciseIds.length === 0) return [];
try {
const result = await dbClient
.select({ exerciseId: schema.submission.exerciseId })
.from(schema.submission)
.innerJoin(schema.groupmember, eq(schema.submission.submittedBy, schema.groupmember.id))
.where(and(inArray(schema.submission.exerciseId, exerciseIds), eq(schema.groupmember.profileId, profileId)));
return result.map((row) => row.exerciseId);
} catch (error) {
console.error('getExerciseCompletionsByProfile error:', error);
throw new Error(
`Failed to get exercise completions for profile "${profileId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getExercisesByLessonIds(lessonIds: string[], dbClient: DbOrTxClient = db): Promise<TExercise[]> {
if (lessonIds.length === 0) return [];
try {
return db.select().from(schema.exercise).where(inArray(schema.exercise.lessonId, lessonIds));
return dbClient.select().from(schema.exercise).where(inArray(schema.exercise.lessonId, lessonIds));
} catch (error) {
console.error('getExercisesByLessonIds error:', error);
throw new Error(
`Failed to get exercises by lesson IDs: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createQuestions(values: TNewQuestion[]) {
if (values.length === 0) return [];
export async function getExercisesByCourseId(
courseId: string,
filters: { lessonId?: string; sectionId?: string } = {},
dbClient: DbOrTxClient = db
): Promise<TExercise[]> {
try {
return db.insert(schema.question).values(values).returning();
const { lessonId, sectionId } = filters;
const conditions = [or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId))];
if (lessonId) {
conditions.push(eq(schema.exercise.lessonId, lessonId));
}
if (sectionId) {
conditions.push(or(eq(schema.exercise.sectionId, sectionId), eq(schema.lesson.sectionId, sectionId)));
}
const results = await dbClient
.select({
exercise: schema.exercise,
sectionId: schema.lesson.sectionId,
lessonCourseId: schema.lesson.courseId
})
.from(schema.exercise)
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.where(and(...conditions));
return results.map((row) => ({
...row.exercise,
courseId: row.exercise.courseId ?? row.lessonCourseId ?? row.exercise.courseId,
sectionId: row.exercise.sectionId ?? row.sectionId
}));
} catch (error) {
throw new Error(`Failed to create questions: ${error instanceof Error ? error.message : 'Unknown error'}`);
console.error('getExercisesByCourseId error:', error);
throw new Error(
`Failed to get exercises by course ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getQuestionsByExerciseIds(exerciseIds: string[]): Promise<TNewQuestion[]> {
export async function updateExercise(exerciseId: string, data: Partial<TExerciseType>, dbClient: DbOrTxClient = db) {
try {
const [updated] = await dbClient
.update(schema.exercise)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(schema.exercise.id, exerciseId))
.returning();
return updated || null;
} catch (error) {
console.error('updateExercise error:', error);
throw new Error(
`Failed to update exercise "${exerciseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateExercisesSectionId(
courseId: string,
sectionId: string,
dbClient: DbOrTxClient = db
): Promise<number> {
try {
const updated = await dbClient
.update(schema.exercise)
.set({ sectionId })
.where(eq(schema.exercise.courseId, courseId))
.returning();
return updated.length;
} catch (error) {
console.error('updateExercisesSectionId error:', error);
throw new Error(
`Failed to update exercises section ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateUngroupedExercisesSectionId(
courseId: string,
sectionId: string,
dbClient: DbOrTxClient = db
): Promise<number> {
try {
const updated = await dbClient
.update(schema.exercise)
.set({ sectionId })
.where(and(eq(schema.exercise.courseId, courseId), isNull(schema.exercise.sectionId)))
.returning();
return updated.length;
} catch (error) {
console.error('updateUngroupedExercisesSectionId error:', error);
throw new Error(
`Failed to update ungrouped exercises section ID "${courseId}": ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
export async function deleteExercise(exerciseId: string, dbClient: DbOrTxClient = db) {
try {
const [deleted] = await dbClient.delete(schema.exercise).where(eq(schema.exercise.id, exerciseId)).returning();
return deleted || null;
} catch (error) {
console.error('deleteExercise error:', error);
throw new Error(
`Failed to delete exercise "${exerciseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createQuestions(values: TNewQuestion[], dbClient: DbOrTxClient = db) {
if (values.length === 0) return [];
try {
return dbClient.insert(schema.question).values(values).returning();
} catch (error) {
const err = error as Error & { code?: string; cause?: unknown };
console.error('createQuestions error:', {
message: err.message,
code: err.code,
cause: err.cause
});
throw new Error('Failed to create questions');
}
}
export async function getQuestionsByExerciseIds(
exerciseIds: string[],
dbClient: DbOrTxClient = db
): Promise<TNewQuestion[]> {
if (exerciseIds.length === 0) return [];
try {
return db.select().from(schema.question).where(inArray(schema.question.exerciseId, exerciseIds));
return dbClient.select().from(schema.question).where(inArray(schema.question.exerciseId, exerciseIds));
} catch (error) {
console.error('getQuestionsByExerciseIds error:', error);
throw new Error(
`Failed to get questions by exercise IDs: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createOptions(values: TNewOption[]) {
export async function updateQuestion(questionId: number, data: Partial<TNewQuestion>, dbClient: DbOrTxClient = db) {
try {
const [updated] = await dbClient
.update(schema.question)
.set(data)
.where(eq(schema.question.id, questionId))
.returning();
return updated || null;
} catch (error) {
console.error('updateQuestion error:', error);
throw new Error(
`Failed to update question "${questionId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function deleteQuestion(questionId: number, dbClient: DbOrTxClient = db) {
try {
const [deleted] = await dbClient.delete(schema.question).where(eq(schema.question.id, questionId)).returning();
return deleted || null;
} catch (error) {
console.error('deleteQuestion error:', error);
throw new Error(
`Failed to delete question "${questionId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createOptions(values: TNewOption[], dbClient: DbOrTxClient = db) {
if (values.length === 0) return [];
try {
return db.insert(schema.option).values(values).returning();
return dbClient.insert(schema.option).values(values).returning();
} catch (error) {
console.error('createOptions error:', error);
throw new Error(`Failed to create options: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getOptionsByQuestionIds(questionIds: number[]) {
export async function getOptionsByQuestionIds(questionIds: number[], dbClient: DbOrTxClient = db) {
if (questionIds.length === 0) return [];
try {
return db.select().from(schema.option).where(inArray(schema.option.questionId, questionIds));
return dbClient
.select()
.from(schema.option)
.where(inArray(schema.option.questionId, questionIds))
.orderBy(asc(schema.option.questionId), asc(schema.option.id));
} catch (error) {
console.error('getOptionsByQuestionIds error:', error);
throw new Error(
`Failed to get options by question IDs: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateOption(optionId: number, data: Partial<TNewOption>, dbClient: DbOrTxClient = db) {
try {
const [updated] = await dbClient.update(schema.option).set(data).where(eq(schema.option.id, optionId)).returning();
return updated || null;
} catch (error) {
console.error('updateOption error:', error);
throw new Error(
`Failed to update option "${optionId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function deleteOption(optionId: number, dbClient: DbOrTxClient = db) {
try {
const [deleted] = await dbClient.delete(schema.option).where(eq(schema.option.id, optionId)).returning();
return deleted || null;
} catch (error) {
console.error('deleteOption error:', error);
throw new Error(
`Failed to delete option "${optionId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Deletes all options for given question IDs in a single query
*/
export async function deleteOptionsByQuestionIds(questionIds: number[], dbClient: DbOrTxClient = db) {
if (questionIds.length === 0) return [];
try {
return dbClient.delete(schema.option).where(inArray(schema.option.questionId, questionIds)).returning();
} catch (error) {
console.error('deleteOptionsByQuestionIds error:', error);
throw new Error(
`Failed to delete options by question IDs: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Deletes questions by IDs in a single query
*/
export async function deleteQuestionsByIds(questionIds: number[], dbClient: DbOrTxClient = db) {
if (questionIds.length === 0) return [];
try {
return dbClient.delete(schema.question).where(inArray(schema.question.id, questionIds)).returning();
} catch (error) {
console.error('deleteQuestionsByIds error:', error);
throw new Error(`Failed to delete questions by IDs: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Deletes options by IDs in a single query
*/
export async function deleteOptionsByIds(optionIds: number[], dbClient: DbOrTxClient = db) {
if (optionIds.length === 0) return [];
try {
return dbClient.delete(schema.option).where(inArray(schema.option.id, optionIds)).returning();
} catch (error) {
console.error('deleteOptionsByIds error:', error);
throw new Error(`Failed to delete options by IDs: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Batch update options - updates multiple options in parallel using a single transaction
*/
export async function updateOptions(
updates: Array<{ id: number; data: Partial<TNewOption> }>,
dbClient?: DbOrTxClient
) {
if (updates.length === 0) return [];
try {
const run = (client: DbOrTxClient) =>
Promise.all(
updates.map(({ id, data }) =>
client
.update(schema.option)
.set(data)
.where(eq(schema.option.id, id))
.returning()
.then((r) => r[0])
)
);
const results = dbClient ? await run(dbClient) : await db.transaction((tx) => run(tx));
return results.filter((r) => r !== undefined);
} catch (error) {
console.error('batchUpdateOptions error:', error);
throw new Error(`Failed to batch update options: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Batch update questions - updates multiple questions in parallel using a single transaction
*/
export async function updateQuestions(
updates: Array<{ id: number; data: Partial<TNewQuestion> }>,
dbClient?: DbOrTxClient
) {
if (updates.length === 0) return [];
try {
const run = (client: DbOrTxClient) =>
Promise.all(
updates.map(({ id, data }) =>
client
.update(schema.question)
.set(data)
.where(eq(schema.question.id, id))
.returning()
.then((r) => r[0])
)
);
const results = dbClient ? await run(dbClient) : await db.transaction((tx) => run(tx));
return results.filter((r) => r !== undefined);
} catch (error) {
console.error('batchUpdateQuestions error:', error);
throw new Error(`Failed to batch update questions: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Gets question types by IDs
* @param ids Array of question type IDs
* @returns Array of question types
*/
export async function getQuestionTypesByIds(ids: number[], dbClient: DbOrTxClient = db): Promise<TQuestionType[]> {
if (ids.length === 0) return [];
try {
return dbClient.select().from(schema.questionType).where(inArray(schema.questionType.id, ids));
} catch (error) {
console.error('getQuestionTypesByIds error:', error);
throw new Error(`Failed to get question types by IDs: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Optimized: Fetches exercise with questions and question types in a single query using JOINs
* @param exerciseId Exercise ID
* @returns Exercise with questions (including questionType) and options separately
*/
export async function getExerciseWithRelationsOptimized(
exerciseId: string,
dbClient: DbOrTxClient = db
): Promise<{
exercise: TExercise;
questions: (TNewQuestion & { questionType: TQuestionType; options: TNewOption[] })[];
}> {
try {
const result = await dbClient
.select({
exercise: schema.exercise,
question: schema.question,
questionType: schema.questionType,
option: schema.option
})
.from(schema.exercise)
.leftJoin(schema.question, eq(schema.question.exerciseId, schema.exercise.id))
.leftJoin(schema.questionType, eq(schema.question.questionTypeId, schema.questionType.id))
.leftJoin(schema.option, eq(schema.option.questionId, schema.question.id))
.where(eq(schema.exercise.id, exerciseId));
if (result.length === 0 || !result[0].exercise) {
throw new Error(`Exercise not found: ${exerciseId}`);
}
const exercise = result[0].exercise;
// Group questions with their question types and options (deduplicate by question ID, collect options)
const questionsMap = new Map<number, TNewQuestion & { questionType: TQuestionType; options: TNewOption[] }>();
for (const row of result) {
if (row.question && row.questionType) {
if (!questionsMap.has(row.question.id)) {
questionsMap.set(row.question.id, {
...row.question,
questionType: row.questionType,
options: []
});
}
// Add option if it exists and hasn't been added yet
if (row.option && row.option.id) {
const question = questionsMap.get(row.question.id)!;
const optionId = row.option.id;
// Check if option already added (avoid duplicates from JOIN)
if (!question.options.some((opt) => opt.id === optionId)) {
question.options.push(row.option);
}
}
}
}
// Sort options by id so order matches insertion order (order sent from client)
for (const question of questionsMap.values()) {
question.options.sort((a, b) => (a.id ?? 0) - (b.id ?? 0));
}
const questions = Array.from(questionsMap.values()).sort((a, b) => {
const orderA = a.order ?? 0;
const orderB = b.order ?? 0;
if (orderA !== orderB) return orderA - orderB;
return (a.id ?? 0) - (b.id ?? 0);
});
return {
exercise,
questions
};
} catch (error) {
console.error('getExerciseWithRelations error:', error);
throw new Error(
`Failed to get exercise with relations: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Syncs the option table's identity sequence to the current max(id).
* Call before inserting options to avoid duplicate key on option_pkey when the sequence is behind.
*/
export async function syncOptionIdSequence(dbClient: DbOrTxClient = db): Promise<void> {
await dbClient.execute(
sql`SELECT setval(pg_get_serial_sequence('option', 'id'), COALESCE((SELECT MAX(id) FROM "option"), 0))`
);
}
@@ -1 +1,3 @@
export * from './exercise';
export * from './exercise';
export * from './lms';
+193
View File
@@ -0,0 +1,193 @@
import * as schema from '@db/schema';
import { and, eq, inArray, or, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
export interface LMSExerciseQueryResult {
id: string;
title: string;
updated_at: string;
questions: {
points: number;
}[];
submission: {
status_id: number;
updated_at: string;
total: number;
groupmember: {
id: string;
profile: {
id: string;
}[];
}[];
}[];
lesson: {
id: string;
title: string;
order: number;
course: {
id: string;
title: string;
group: {
organisation: {
id: string;
}[];
}[];
groupmember: {
id: string;
profile: {
id: string;
}[];
}[];
};
};
}
/**
* Gets exercises with submissions for a student in an organization
* Returns exercises from unlocked lessons in courses where the student is a member
* @param profileId - The profile ID of the student
* @param orgId - The organization ID
* @returns Array of exercises with related data
*/
export async function getLMSExercises(profileId: string, orgId: string): Promise<LMSExerciseQueryResult[]> {
try {
// Get all courses in the organization where the user is a group member
const coursesWithMembership = await db
.select({
courseId: schema.course.id,
courseTitle: schema.course.title,
groupId: schema.group.id,
groupMemberId: schema.groupmember.id
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.groupmember, eq(schema.group.id, schema.groupmember.groupId))
.where(and(eq(schema.group.organizationId, orgId), eq(schema.groupmember.profileId, profileId)));
if (coursesWithMembership.length === 0) {
return [];
}
const courseIds = [...new Set(coursesWithMembership.map((c) => c.courseId))];
const exercises = await db
.select({
id: schema.exercise.id,
title: schema.exercise.title,
updatedAt: schema.exercise.updatedAt,
lessonId: schema.exercise.lessonId,
lessonTitle: schema.lesson.title,
lessonOrder: schema.lesson.order,
exerciseCourseId: sql<string>`COALESCE(${schema.exercise.courseId}, ${schema.lesson.courseId})`.as(
'exercise_course_id'
)
})
.from(schema.exercise)
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.where(or(inArray(schema.exercise.courseId, courseIds), inArray(schema.lesson.courseId, courseIds)));
if (exercises.length === 0) {
return [];
}
const exerciseIds = exercises.map((e) => e.id);
// Get questions for these exercises
const questions = await db
.select({
exerciseId: schema.question.exerciseId,
points: schema.question.points
})
.from(schema.question)
.where(inArray(schema.question.exerciseId, exerciseIds));
// Get groupmember IDs for the user in these courses
const groupMemberIds = [...new Set(coursesWithMembership.map((c) => c.groupMemberId))];
// Get submissions for these exercises by this user
const submissions = await db
.select({
id: schema.submission.id,
exerciseId: schema.submission.exerciseId,
statusId: schema.submission.statusId,
total: schema.submission.total,
updatedAt: schema.submission.updatedAt,
submittedBy: schema.submission.submittedBy
})
.from(schema.submission)
.where(
and(inArray(schema.submission.exerciseId, exerciseIds), inArray(schema.submission.submittedBy, groupMemberIds))
);
// Build the result structure matching the original interface
const result: LMSExerciseQueryResult[] = exercises.map((exercise) => {
const courseData = coursesWithMembership.find((c) => c.courseId === exercise.exerciseCourseId);
const exerciseQuestions = questions.filter((q) => q.exerciseId === exercise.id);
const exerciseSubmissions = submissions.filter((s) => s.exerciseId === exercise.id);
const groupMemberId = courseData?.groupMemberId;
return {
id: exercise.id,
title: exercise.title,
updated_at: exercise.updatedAt || '',
questions: exerciseQuestions.map((q) => ({ points: q.points || 0 })),
submission:
exerciseSubmissions.length > 0
? exerciseSubmissions.map((sub) => ({
status_id: Number(sub.statusId) || 0,
updated_at: sub.updatedAt || '',
total: Number(sub.total) || 0,
groupmember: groupMemberId
? [
{
id: groupMemberId,
profile: [
{
id: profileId
}
]
}
]
: []
}))
: [],
lesson: {
id: exercise.lessonId || '',
title: exercise.lessonTitle || '',
order: Number(exercise.lessonOrder) || 0,
course: {
id: courseData?.courseId || '',
title: courseData?.courseTitle || '',
group: [
{
organisation: [
{
id: orgId
}
]
}
],
groupmember: groupMemberId
? [
{
id: groupMemberId,
profile: [
{
id: profileId
}
]
}
]
: []
}
}
};
});
return result;
} catch (error) {
console.error('getLmsExercises error:', error);
throw new Error(`Failed to get LMS exercises: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
+93 -14
View File
@@ -1,7 +1,7 @@
import * as schema from '@db/schema';
import { TNewGroup, TNewGroupmember } from '@db/types';
import { and, eq, or } from 'drizzle-orm';
import { and, eq, isNotNull, or } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
@@ -10,6 +10,7 @@ export async function createGroup(values: TNewGroup) {
try {
return db.insert(schema.group).values(values).returning();
} catch (error) {
console.error('createGroup error:', error);
throw new Error(`Failed to create group: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
@@ -18,6 +19,7 @@ export async function addGroupMember(values: TNewGroupmember) {
try {
return db.insert(schema.groupmember).values(values).returning();
} catch (error) {
console.error('addGroupMember error:', error);
throw new Error(`Failed to add group member: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
@@ -26,16 +28,57 @@ export async function addGroupMember(values: TNewGroupmember) {
* Checks if a user is a member of a course's group (any role)
* @param courseId Course ID
* @param profileId Profile ID to check
* @returns True if user is a member of the course's group, false otherwise
*/
export const isUserCourseMember = async (courseId: string, profileId: string): Promise<boolean> => {
export const isUserCourseMember = async (
courseId: string,
profileId: string
): Promise<{ isMember: boolean; organizationId: string | null }> => {
const result = await db
.select({ roleId: schema.groupmember.roleId })
.select({ organizationId: schema.group.organizationId, roleId: schema.groupmember.roleId })
.from(schema.groupmember)
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.where(and(eq(schema.course.id, courseId), eq(schema.groupmember.profileId, profileId)))
.limit(1);
return {
isMember: result.length > 0,
organizationId: result[0].organizationId
};
};
/**
* Checks if a user is either:
* - a member of the course's group (any role), OR
* - an ADMIN of the organization that owns the course's group.
*
* This is designed for middleware use to avoid doing multiple DB queries.
*/
export const isUserCourseMemberOrOrgAdmin = async (courseId: string, profileId: string): Promise<boolean> => {
const result = await db
.select({
groupMemberId: schema.groupmember.id,
orgMemberId: schema.organizationmember.id
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.leftJoin(
schema.groupmember,
and(eq(schema.groupmember.groupId, schema.group.id), eq(schema.groupmember.profileId, profileId))
)
.leftJoin(
schema.organizationmember,
and(
eq(schema.organizationmember.organizationId, schema.group.organizationId),
eq(schema.organizationmember.profileId, profileId),
eq(schema.organizationmember.roleId, ROLE.ADMIN)
)
)
.where(
and(eq(schema.course.id, courseId), or(isNotNull(schema.groupmember.id), isNotNull(schema.organizationmember.id)))
)
.limit(1);
return result.length > 0;
};
@@ -57,24 +100,60 @@ export const getUserCourseRole = async (courseId: string, profileId: string): Pr
};
/**
* Checks if a user is a team member (ADMIN or TUTOR) in a course's group
* @param courseId Course ID
* @param profileId Profile ID to check
* @returns True if user is ADMIN or TUTOR in the course's group, false otherwise
* Checks if a user is either:
* - a team member (ADMIN or TUTOR) of the course's group, OR
* - an ADMIN of the organization that owns the course's group.
*
* This is designed for middleware use to avoid doing multiple DB queries.
*/
export const isUserCourseTeamMember = async (courseId: string, profileId: string): Promise<boolean> => {
export const isCourseTeamMemberOrOrgAdmin = async (courseId: string, profileId: string): Promise<boolean> => {
const result = await db
.select({ roleId: schema.groupmember.roleId })
.from(schema.groupmember)
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
.where(
.select({
groupMemberId: schema.groupmember.id,
orgMemberId: schema.organizationmember.id
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.leftJoin(
schema.groupmember,
and(
eq(schema.course.id, courseId),
eq(schema.groupmember.groupId, schema.group.id),
eq(schema.groupmember.profileId, profileId),
or(eq(schema.groupmember.roleId, ROLE.ADMIN), eq(schema.groupmember.roleId, ROLE.TUTOR))
)
)
.leftJoin(
schema.organizationmember,
and(
eq(schema.organizationmember.organizationId, schema.group.organizationId),
eq(schema.organizationmember.profileId, profileId),
eq(schema.organizationmember.roleId, ROLE.ADMIN)
)
)
.where(
and(eq(schema.course.id, courseId), or(isNotNull(schema.groupmember.id), isNotNull(schema.organizationmember.id)))
)
.limit(1);
return result.length > 0;
};
/**
* Gets the group member ID for a user in a course
* @param courseId Course ID
* @param profileId Profile ID
* @returns Group member ID or null if not found
*/
export const getGroupMemberIdByCourseAndProfile = async (
courseId: string,
profileId: string
): Promise<string | null> => {
const result = await db
.select({ id: schema.groupmember.id })
.from(schema.groupmember)
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
.where(and(eq(schema.course.id, courseId), eq(schema.groupmember.profileId, profileId)))
.limit(1);
return result.length > 0 ? result[0].id : null;
};
+7
View File
@@ -1,6 +1,13 @@
export * from './organization';
export * from './auth';
export * from './attendance';
export * from './assets';
export * from './course';
export * from './group';
export * from './lesson';
export * from './exercise';
export * from './analytics';
export * from './mark';
export * from './newsfeed';
export * from './submission';
export * from './tag';
+1
View File
@@ -1 +1,2 @@
export * from './lesson';
export * from './language';
@@ -0,0 +1,56 @@
import * as schema from '@db/schema';
import type { TLessonLanguage, TLocale, TNewLessonLanguage } from '@db/types';
import { and, eq } from 'drizzle-orm';
import { db } from '@db/drizzle';
export async function getLessonLanguagesByLessonId(lessonId: string): Promise<TLessonLanguage[]> {
const languages = await db.select().from(schema.lessonLanguage).where(eq(schema.lessonLanguage.lessonId, lessonId));
return languages;
}
export async function getLessonLanguageByLessonIdAndLocale(
lessonId: string,
locale: TLocale
): Promise<TLessonLanguage | null> {
const [language] = await db
.select()
.from(schema.lessonLanguage)
.where(and(eq(schema.lessonLanguage.lessonId, lessonId), eq(schema.lessonLanguage.locale, locale)))
.limit(1);
return language || null;
}
export async function createLessonLanguage(data: TNewLessonLanguage): Promise<TLessonLanguage> {
const [language] = await db.insert(schema.lessonLanguage).values(data).returning();
return language;
}
export async function updateLessonLanguage(
lessonId: string,
locale: TLocale,
data: Partial<TNewLessonLanguage>
): Promise<TLessonLanguage> {
const [language] = await db
.update(schema.lessonLanguage)
.set(data)
.where(and(eq(schema.lessonLanguage.lessonId, lessonId), eq(schema.lessonLanguage.locale, locale)))
.returning();
return language;
}
export async function upsertLessonLanguage(data: TNewLessonLanguage): Promise<TLessonLanguage> {
// Check if exists
const existing = await getLessonLanguageByLessonIdAndLocale(data.lessonId!, data.locale || 'en');
if (existing) {
return updateLessonLanguage(data.lessonId!, data.locale || 'en', data);
}
return createLessonLanguage(data);
}
+311 -18
View File
@@ -1,48 +1,303 @@
import { db, eq, inArray } from '@db/drizzle';
import * as schema from '@db/schema';
import { TNewLesson, TNewLessonSection, TNewLessonLanguage } from '@db/types';
export async function getSectionsByCourseId(courseId: string) {
try {
return db.select().from(schema.lessonSection).where(eq(schema.lessonSection.courseId, courseId));
} catch (error) {
throw new Error(
`Failed to get sections by course ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
import type {
TLesson,
TLessonComment,
TLessonCompletion,
TLessonLanguage,
TNewLesson,
TNewLessonComment,
TNewLessonCompletion,
TNewLessonLanguage
} from '@db/types';
import { and, db, desc, eq, inArray, lt, sql } from '@db/drizzle';
export async function createLessonSections(values: TNewLessonSection[]) {
try {
return db.insert(schema.lessonSection).values(values).returning();
} catch (error) {
throw new Error(`Failed to create lesson sections: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
import type { DbOrTxClient } from '@db/drizzle';
// Lesson Queries
export async function getLessonsByCourseId(courseId: string) {
try {
return db.select().from(schema.lesson).where(eq(schema.lesson.courseId, courseId));
} catch (error) {
console.error('getLessonsByCourseId error:', error);
throw new Error(
`Failed to get lessons by course ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export interface LessonById extends TLesson {
lessonLanguages: TLessonLanguage[];
}
export async function getLessonById(lessonId: string): Promise<LessonById | null> {
try {
const [lesson] = await db.select().from(schema.lesson).where(eq(schema.lesson.id, lessonId)).limit(1);
if (!lesson) return null;
// Fetch lesson languages
const lessonLanguages = await db
.select()
.from(schema.lessonLanguage)
.where(eq(schema.lessonLanguage.lessonId, lessonId));
return {
...lesson,
lessonLanguages
};
} catch (error) {
console.error('getLessonById error:', error);
throw new Error(
`Failed to get lesson by ID "${lessonId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createLessons(values: TNewLesson[]) {
try {
return db.insert(schema.lesson).values(values).returning();
} catch (error) {
console.error('createLessons error:', error);
throw new Error(`Failed to create lessons: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function updateLesson(lessonId: string, data: Partial<TLesson>, dbClient: DbOrTxClient = db) {
try {
const [updated] = await dbClient
.update(schema.lesson)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(schema.lesson.id, lessonId))
.returning();
return updated || null;
} catch (error) {
console.error('updateLesson error:', error);
throw new Error(
`Failed to update lesson "${lessonId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function deleteLesson(lessonId: string, dbClient: DbOrTxClient = db) {
try {
const [deleted] = await dbClient.delete(schema.lesson).where(eq(schema.lesson.id, lessonId)).returning();
return deleted || null;
} catch (error) {
console.error('deleteLesson error:', error);
throw new Error(
`Failed to delete lesson "${lessonId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
// Lesson Comment Queries
export async function getLessonCommentsByLessonId(lessonId: string) {
try {
const comments = await db
.select({
comment: schema.lessonComment,
groupmember: schema.groupmember,
profile: schema.profile
})
.from(schema.lessonComment)
.leftJoin(schema.groupmember, eq(schema.lessonComment.groupmemberId, schema.groupmember.id))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(eq(schema.lessonComment.lessonId, lessonId))
.orderBy(desc(schema.lessonComment.createdAt));
return comments.map((row) => ({
...row.comment,
groupmember: row.groupmember
? {
id: row.groupmember.id
}
: null,
profile: row.profile
? {
fullname: row.profile.fullname,
avatarUrl: row.profile.avatarUrl
}
: null
}));
} catch (error) {
console.error('getLessonCommentsByLessonId error:', error);
throw new Error(
`Failed to get lesson comments for lesson "${lessonId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Gets paginated comments for a lesson with author profile
* @param lessonId Lesson ID
* @param options Pagination options (cursor, limit)
* @returns Object with comments array, total count, hasMore flag, and nextCursor
*/
export async function getLessonCommentsByLessonIdPaginated(
lessonId: string,
options: { cursor?: string; limit: number }
): Promise<{
items: Array<
TLessonComment & {
groupmember: { id: string } | null;
profile: { fullname: string | null; avatarUrl: string | null } | null;
}
>;
totalCount: number;
hasMore: boolean;
nextCursor: string | null;
}> {
try {
const { cursor, limit } = options;
// Build where conditions
const whereConditions = [eq(schema.lessonComment.lessonId, lessonId)];
if (cursor) {
// Cursor is the last comment ID, fetch comments created before it
whereConditions.push(lt(schema.lessonComment.id, Number(cursor)));
}
// Get total count
const totalCountResult = await db
.select({ count: sql<number>`count(*)`.as('count') })
.from(schema.lessonComment)
.where(eq(schema.lessonComment.lessonId, lessonId));
const totalCount = Number(totalCountResult[0]?.count || 0);
// Fetch comments with author profile (limit + 1 to check if there are more)
const comments = await db
.select({
comment: schema.lessonComment,
groupmember: schema.groupmember,
profile: schema.profile
})
.from(schema.lessonComment)
.leftJoin(schema.groupmember, eq(schema.lessonComment.groupmemberId, schema.groupmember.id))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(and(...whereConditions))
.orderBy(desc(schema.lessonComment.createdAt))
.limit(limit + 1);
// Check if there are more comments
const hasMore = comments.length > limit;
const items = comments.slice(0, limit);
// Get next cursor (ID of the oldest comment in this batch - for loading older comments)
const nextCursor = hasMore && items.length > 0 ? String(items[items.length - 1].comment.id) : null;
return {
items: items.map((row) => ({
...row.comment,
groupmember: row.groupmember
? {
id: row.groupmember.id
}
: null,
profile: row.profile
? {
fullname: row.profile.fullname,
avatarUrl: row.profile.avatarUrl
}
: null
})),
totalCount,
hasMore,
nextCursor
};
} catch (error) {
console.error('getLessonCommentsByLessonIdPaginated error:', error);
throw new Error(
`Failed to get paginated lesson comments for lesson "${lessonId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createLessonComment(data: TNewLessonComment): Promise<TLessonComment> {
try {
const [comment] = await db.insert(schema.lessonComment).values(data).returning();
if (!comment) {
throw new Error('Failed to create lesson comment');
}
return comment;
} catch (error) {
console.error('createLessonComment error:', error);
throw new Error(`Failed to create lesson comment: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function updateLessonComment(commentId: number, comment: string): Promise<TLessonComment | null> {
try {
const [updated] = await db
.update(schema.lessonComment)
.set({ comment, updatedAt: new Date().toISOString() })
.where(eq(schema.lessonComment.id, commentId))
.returning();
return updated || null;
} catch (error) {
console.error('updateLessonComment error:', error);
throw new Error(
`Failed to update lesson comment "${commentId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function deleteLessonComment(commentId: number) {
try {
const [deleted] = await db.delete(schema.lessonComment).where(eq(schema.lessonComment.id, commentId)).returning();
return deleted || null;
} catch (error) {
console.error('deleteLessonComment error:', error);
throw new Error(
`Failed to delete lesson comment "${commentId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
// Lesson Completion Queries
export async function getLessonCompletion(lessonId: string, profileId: string): Promise<TLessonCompletion | null> {
try {
const [completion] = await db
.select()
.from(schema.lessonCompletion)
.where(and(eq(schema.lessonCompletion.lessonId, lessonId), eq(schema.lessonCompletion.profileId, profileId)))
.limit(1);
return completion || null;
} catch (error) {
console.error('getLessonCompletion error:', error);
throw new Error(`Failed to get lesson completion: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function upsertLessonCompletion(data: TNewLessonCompletion): Promise<TLessonCompletion> {
try {
const [completion] = await db
.insert(schema.lessonCompletion)
.values(data)
.onConflictDoUpdate({
target: [schema.lessonCompletion.lessonId, schema.lessonCompletion.profileId],
set: {
isComplete: data.isComplete,
updatedAt: new Date().toISOString()
}
})
.returning();
if (!completion) {
throw new Error('Failed to upsert lesson completion');
}
return completion;
} catch (error) {
console.error('upsertLessonCompletion error:', error);
throw new Error(`Failed to upsert lesson completion: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Lesson Language Queries
export async function getLessonLanguagesByLessonIds(lessonIds: string[]) {
if (lessonIds.length === 0) return [];
try {
return db.select().from(schema.lessonLanguage).where(inArray(schema.lessonLanguage.lessonId, lessonIds));
} catch (error) {
console.error('getLessonLanguagesByLessonIds error:', error);
throw new Error(
`Failed to get lesson languages by lesson IDs: ${error instanceof Error ? error.message : 'Unknown error'}`
);
@@ -54,6 +309,44 @@ export async function createLessonLanguages(values: TNewLessonLanguage[]) {
try {
return db.insert(schema.lessonLanguage).values(values).returning();
} catch (error) {
console.error('createLessonLanguages error:', error);
throw new Error(`Failed to create lesson languages: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Gets lesson version history for a lesson and locale
* @param lessonId Lesson ID
* @param locale Locale
* @param endRange End range for pagination (0-indexed, inclusive)
* @returns Array of lesson version history entries
*/
export async function getLessonVersionHistory(
lessonId: string,
locale: string,
endRange: number
): Promise<
Array<{
oldContent: string | null;
newContent: string | null;
timestamp: string | null;
locale: string | null;
lessonId: string | null;
}>
> {
try {
const result = await db
.select()
.from(schema.lessonVersions)
.where(and(eq(schema.lessonVersions.lessonId, lessonId), eq(schema.lessonVersions.locale, locale as any)))
.orderBy(desc(schema.lessonVersions.timestamp))
.limit(endRange + 1);
return result;
} catch (error) {
console.error('getLessonVersionHistory error:', error);
throw new Error(
`Failed to get lesson version history: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
+1
View File
@@ -0,0 +1 @@
export * from './mark';
+71
View File
@@ -0,0 +1,71 @@
import * as schema from '@db/schema';
import { and, db, eq, or, sql } from '@db/drizzle';
/**
* Mark record with fields in camelCase matching database schema convention.
* Slim shape for gradebook: no lesson or profile data (students come from members API).
*/
export interface Mark {
courseId: string;
exerciseId: string;
exerciseTitle: string;
exercisePoints: number;
statusId: number | null;
totalPointsGotten: number | null;
groupmemberId: string | null;
}
/**
* Gets marks for a course
* Converts the get_marks() PostgreSQL function to a Drizzle query
* @param courseId Course ID
* @returns Array of mark records
*/
export async function getMarksByCourseId(courseId: string): Promise<Mark[]> {
try {
const result = await db
.select({
courseId: schema.course.id,
exerciseId: schema.exercise.id,
exerciseTitle: schema.exercise.title,
exercisePoints: sql<number>`COALESCE(SUM(${schema.question.points})::int, 0)`.as('exercisePoints'),
statusId: schema.submission.statusId,
totalPointsGotten: schema.submission.total,
groupmemberId: schema.submission.submittedBy
})
.from(schema.exercise)
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.innerJoin(
schema.course,
or(eq(schema.exercise.courseId, schema.course.id), eq(schema.lesson.courseId, schema.course.id))
)
.leftJoin(schema.submission, eq(schema.exercise.id, schema.submission.exerciseId))
.innerJoin(schema.question, eq(schema.question.exerciseId, schema.exercise.id))
.where(eq(schema.course.id, courseId))
.groupBy(
schema.exercise.id,
schema.course.id,
schema.exercise.title,
schema.submission.statusId,
schema.submission.total,
schema.submission.submittedBy
)
.orderBy(schema.exercise.createdAt);
return result.map((row) => ({
courseId: row.courseId,
exerciseId: row.exerciseId,
exerciseTitle: row.exerciseTitle,
exercisePoints: row.exercisePoints ?? 0,
statusId: row.statusId ?? null,
totalPointsGotten: row.totalPointsGotten ?? null,
groupmemberId: row.groupmemberId ?? null
}));
} catch (error) {
console.error('getMarksByCourseId error:', error);
throw new Error(
`Failed to get marks for course ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
@@ -0,0 +1 @@
export * from './newsfeed';
@@ -0,0 +1,489 @@
import * as schema from '@db/schema';
import type { TCourseNewsfeed, TCourseNewsfeedComment, TNewCourseNewsfeed, TNewCourseNewsfeedComment } from '@db/types';
import { and, db, desc, eq, lt, ne, sql } from '@db/drizzle';
/**
* Gets newsfeed items by course ID
* @param courseId Course ID
* @returns Array of newsfeed items with flattened author profile fields
*/
export async function getNewsfeedByCourseId(courseId: string): Promise<
(TCourseNewsfeed & {
authorProfileId: string | null;
authorFullname: string | null;
authorUsername: string | null;
authorAvatarUrl: string | null;
})[]
> {
try {
const feeds = await db
.select({
feed: schema.courseNewsfeed,
profile: schema.profile
})
.from(schema.courseNewsfeed)
.leftJoin(schema.groupmember, eq(schema.courseNewsfeed.authorId, schema.groupmember.id))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(eq(schema.courseNewsfeed.courseId, courseId))
.orderBy(desc(schema.courseNewsfeed.createdAt));
return feeds.map((row) => ({
...row.feed,
authorProfileId: row.profile?.id || null,
authorFullname: row.profile?.fullname || null,
authorUsername: row.profile?.username || null,
authorAvatarUrl: row.profile?.avatarUrl || null
}));
} catch (error) {
console.error('getNewsfeedByCourseId error:', error);
throw new Error(
`Failed to get newsfeed by course ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Gets paginated newsfeed items by course ID
* @param courseId Course ID
* @param options Pagination options (cursor, limit)
* @returns Object with feeds array, total count, hasMore flag, and nextCursor
*/
export async function getNewsfeedByCourseIdPaginated(
courseId: string,
options: { cursor?: string; limit: number }
): Promise<{
items: (TCourseNewsfeed & {
authorProfileId: string | null;
authorFullname: string | null;
authorUsername: string | null;
authorAvatarUrl: string | null;
commentCount: number;
})[];
totalCount: number;
hasMore: boolean;
nextCursor: string | null;
}> {
try {
const { cursor, limit } = options;
// Build where conditions
const whereConditions = [eq(schema.courseNewsfeed.courseId, courseId)];
if (cursor) {
// Cursor is the last feed ID, fetch feeds created before it
whereConditions.push(lt(schema.courseNewsfeed.createdAt, cursor));
}
// Get total count
const totalCountResult = await db
.select({ count: sql<number>`count(*)`.as('count') })
.from(schema.courseNewsfeed)
.where(eq(schema.courseNewsfeed.courseId, courseId));
const totalCount = Number(totalCountResult[0]?.count || 0);
// Fetch feeds with author profile and comment count (limit + 1 to check if there are more)
const feeds = await db
.select({
feed: schema.courseNewsfeed,
profile: schema.profile,
commentCount: getCommentCount()
})
.from(schema.courseNewsfeed)
.leftJoin(schema.groupmember, eq(schema.courseNewsfeed.authorId, schema.groupmember.id))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(and(...whereConditions))
.orderBy(desc(schema.courseNewsfeed.createdAt))
.limit(limit + 1);
// Check if there are more feeds
const hasMore = feeds.length > limit;
const items = feeds.slice(0, limit);
// Get next cursor (createdAt timestamp of the oldest feed in this batch)
const nextCursor = hasMore && items.length > 0 ? items[items.length - 1].feed.createdAt : null;
return {
items: items.map((row) => ({
...row.feed,
authorProfileId: row.profile?.id || null,
authorFullname: row.profile?.fullname || null,
authorUsername: row.profile?.username || null,
authorAvatarUrl: row.profile?.avatarUrl || null,
commentCount: Number(row.commentCount || 0)
})),
totalCount,
hasMore,
nextCursor
};
} catch (error) {
console.error('getNewsfeedByCourseIdPaginated error:', error);
throw new Error(
`Failed to get paginated newsfeed by course ID "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Gets a newsfeed item by ID
* @param feedId Newsfeed ID
* @returns Newsfeed item or null
*/
export async function getNewsfeedById(feedId: string): Promise<TCourseNewsfeed | null> {
try {
const [feed] = await db.select().from(schema.courseNewsfeed).where(eq(schema.courseNewsfeed.id, feedId)).limit(1);
return feed || null;
} catch (error) {
console.error('getNewsfeedById error:', error);
throw new Error(
`Failed to get newsfeed by ID "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Creates a new newsfeed item
* @param data Newsfeed data
* @returns Created newsfeed item
*/
export async function createNewsfeed(data: TNewCourseNewsfeed): Promise<TCourseNewsfeed> {
try {
const [feed] = await db.insert(schema.courseNewsfeed).values(data).returning();
if (!feed) {
throw new Error('Failed to create newsfeed');
}
return feed;
} catch (error) {
console.error('createNewsfeed error:', error);
throw new Error(`Failed to create newsfeed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Updates a newsfeed item
* @param feedId Newsfeed ID
* @param data Partial newsfeed data
* @returns Updated newsfeed item or null
*/
export async function updateNewsfeed(feedId: string, data: Partial<TCourseNewsfeed>): Promise<TCourseNewsfeed | null> {
try {
const [updated] = await db
.update(schema.courseNewsfeed)
.set(data)
.where(eq(schema.courseNewsfeed.id, feedId))
.returning();
return updated || null;
} catch (error) {
console.error('updateNewsfeed error:', error);
throw new Error(
`Failed to update newsfeed "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Deletes a newsfeed item
* @param feedId Newsfeed ID
* @returns Deleted newsfeed item or null
*/
export async function deleteNewsfeed(feedId: string): Promise<TCourseNewsfeed | null> {
try {
const [deleted] = await db.delete(schema.courseNewsfeed).where(eq(schema.courseNewsfeed.id, feedId)).returning();
return deleted || null;
} catch (error) {
console.error('deleteNewsfeed error:', error);
throw new Error(
`Failed to delete newsfeed "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Gets comments for a newsfeed item
* @param feedId Newsfeed ID
* @returns Array of comments
*/
export async function getNewsfeedCommentsByFeedId(feedId: string): Promise<TCourseNewsfeedComment[]> {
try {
return db
.select()
.from(schema.courseNewsfeedComment)
.where(eq(schema.courseNewsfeedComment.courseNewsfeedId, feedId))
.orderBy(schema.courseNewsfeedComment.createdAt);
} catch (error) {
console.error('getNewsfeedCommentsByFeedId error:', error);
throw new Error(
`Failed to get newsfeed comments for feed "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Gets paginated comments for a newsfeed item with author profile
* @param feedId Newsfeed ID
* @param options Pagination options (cursor, limit)
* @returns Object with comments array, total count, hasMore flag, and nextCursor
*/
export async function getNewsfeedCommentsByFeedIdPaginated(
feedId: string,
options: { cursor?: string; limit: number }
) {
try {
const { cursor, limit } = options;
// Build where conditions
const whereConditions = [eq(schema.courseNewsfeedComment.courseNewsfeedId, feedId)];
if (cursor) {
// Cursor is the last comment ID, fetch comments created after it
whereConditions.push(lt(schema.courseNewsfeedComment.id, Number(cursor)));
}
// Get total count
const totalCountResult = await db
.select({ count: sql<number>`count(*)`.as('count') })
.from(schema.courseNewsfeedComment)
.where(eq(schema.courseNewsfeedComment.courseNewsfeedId, feedId));
const totalCount = Number(totalCountResult[0]?.count || 0);
// Fetch comments with author profile (limit + 1 to check if there are more)
const comments = await db
.select({
comment: schema.courseNewsfeedComment,
groupmember: schema.groupmember,
profile: schema.profile
})
.from(schema.courseNewsfeedComment)
.leftJoin(schema.groupmember, eq(schema.courseNewsfeedComment.authorId, schema.groupmember.id))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(and(...whereConditions))
.orderBy(desc(schema.courseNewsfeedComment.createdAt))
.limit(limit + 1);
// Check if there are more comments
const hasMore = comments.length > limit;
const items = comments.slice(0, limit);
// Get next cursor (ID of the oldest comment in this batch - for loading older comments)
const nextCursor = hasMore && items.length > 0 ? String(items[items.length - 1].comment.id) : null;
return {
items: items.map((row) => ({
...row.comment,
authorProfileId: row.profile?.id || null,
authorFullname: row.profile?.fullname || null,
authorUsername: row.profile?.username || null,
authorAvatarUrl: row.profile?.avatarUrl || null
})),
totalCount,
hasMore,
nextCursor
};
} catch (error) {
console.error('getNewsfeedCommentsByFeedIdPaginated error:', error);
throw new Error(
`Failed to get paginated newsfeed comments for feed "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Creates a newsfeed comment
* @param data Comment data
* @returns Created comment
*/
export async function createNewsfeedComment(data: TNewCourseNewsfeedComment): Promise<TCourseNewsfeedComment> {
try {
const [comment] = await db.insert(schema.courseNewsfeedComment).values(data).returning();
if (!comment) {
throw new Error('Failed to create newsfeed comment');
}
return comment;
} catch (error) {
console.error('createNewsfeedComment error:', error);
throw new Error(`Failed to create newsfeed comment: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Deletes a newsfeed comment
* @param commentId Comment ID
* @returns Deleted comment or null
*/
export async function updateNewsfeedComment(
commentId: number,
content: string
): Promise<TCourseNewsfeedComment | null> {
try {
const [updated] = await db
.update(schema.courseNewsfeedComment)
.set({ content })
.where(eq(schema.courseNewsfeedComment.id, commentId))
.returning();
return updated || null;
} catch (error) {
console.error('updateNewsfeedComment error:', error);
throw new Error(
`Failed to update newsfeed comment "${commentId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function deleteNewsfeedComment(commentId: number): Promise<TCourseNewsfeedComment | null> {
try {
const [deleted] = await db
.delete(schema.courseNewsfeedComment)
.where(eq(schema.courseNewsfeedComment.id, commentId))
.returning();
return deleted || null;
} catch (error) {
console.error('deleteNewsfeedComment error:', error);
throw new Error(
`Failed to delete newsfeed comment "${commentId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Gets comment author and course ID for authorization checks
* @param commentId Comment ID
* @returns Object with authorId (group member ID) and courseId, or null if not found
*/
export async function getNewsfeedCommentAuthorAndCourse(commentId: number): Promise<{
authorId: string | null;
courseId: string | null;
} | null> {
try {
const result = await db
.select({
authorId: schema.courseNewsfeedComment.authorId,
courseId: schema.courseNewsfeed.courseId
})
.from(schema.courseNewsfeedComment)
.innerJoin(schema.courseNewsfeed, eq(schema.courseNewsfeedComment.courseNewsfeedId, schema.courseNewsfeed.id))
.where(eq(schema.courseNewsfeedComment.id, commentId))
.limit(1);
if (result.length === 0) {
return null;
}
return {
authorId: result[0].authorId,
courseId: result[0].courseId
};
} catch (error) {
console.error('getNewsfeedCommentAuthorAndCourse error:', error);
throw new Error(
`Failed to get newsfeed comment author for comment "${commentId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Gets feed data with course, organization, author, and members info for email notifications
* @param feedId Feed ID
* @param excludeAuthorId Group member ID to exclude from course members list
* @returns Feed data with course, org, author, and members info, or null if not found
*/
export async function getNewsfeedForEmail(
feedId: string,
excludeAuthorId?: string
): Promise<{
feedId: string;
courseId: string | null;
courseTitle: string | null;
content: string | null;
author: {
groupMemberId: string | null;
fullname: string | null;
email: string | null;
} | null;
organization: {
name: string | null;
siteName: string | null;
} | null;
courseMembers: Array<{
email: string | null;
fullname: string | null;
}>;
} | null> {
try {
const result = await db
.select({
feed: schema.courseNewsfeed,
course: schema.course,
organization: schema.organization,
authorGroupMember: schema.groupmember,
authorProfile: schema.profile
})
.from(schema.courseNewsfeed)
.innerJoin(schema.course, eq(schema.courseNewsfeed.courseId, schema.course.id))
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.leftJoin(schema.groupmember, eq(schema.courseNewsfeed.authorId, schema.groupmember.id))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(eq(schema.courseNewsfeed.id, feedId))
.limit(1);
if (result.length === 0) {
return null;
}
const row = result[0];
// Get all course members (excluding author if specified)
const memberConditions = [eq(schema.course.id, row.course.id)];
if (excludeAuthorId) {
memberConditions.push(ne(schema.groupmember.id, excludeAuthorId));
}
const membersResult = await db
.select({
groupMember: schema.groupmember,
profile: schema.profile
})
.from(schema.groupmember)
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(and(...memberConditions));
return {
feedId,
courseId: row.course.id,
courseTitle: row.course.title,
content: row.feed.content,
author: row.authorGroupMember
? {
groupMemberId: row.authorGroupMember.id,
fullname: row.authorProfile?.fullname || null,
email: row.authorProfile?.email || null
}
: null,
organization: {
name: row.organization.name,
siteName: row.organization.siteName
},
courseMembers: membersResult
.filter((m) => m.profile?.email) // Only include members with email
.map((m) => ({
email: m.profile?.email || null,
fullname: m.profile?.fullname || null
}))
};
} catch (error) {
console.error('getNewsfeedForEmail error:', error);
throw new Error(
`Failed to get newsfeed data for email "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
function getCommentCount() {
return sql<number>`
COALESCE(
(SELECT COUNT(*)::int
FROM ${schema.courseNewsfeedComment}
WHERE ${schema.courseNewsfeedComment.courseNewsfeedId} = ${schema.courseNewsfeed.id}),
0
)
`.as('commentCount');
}
@@ -1 +1,3 @@
export * from './organization';
export * from './quiz';
export * from './invite';
@@ -0,0 +1,182 @@
import type {
TNewOrganizationInvite,
TNewOrganizationInviteAudit,
TOrganizationInvite,
TOrganizationInviteAudit
} from '@db/types';
import * as schema from '@db/schema';
import { and, eq, gt, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
export async function createOrganizationInvite(values: TNewOrganizationInvite): Promise<TOrganizationInvite> {
try {
const [created] = await db.insert(schema.organizationInvite).values(values).returning();
if (!created) {
throw new Error('Failed to create organization invite');
}
return created;
} catch (error) {
console.error('createOrganizationInvite error:', error);
throw new Error(
`Failed to create organization invite: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createOrganizationInviteAudit(
values: TNewOrganizationInviteAudit
): Promise<TOrganizationInviteAudit> {
try {
const [created] = await db.insert(schema.organizationInviteAudit).values(values).returning();
if (!created) {
throw new Error('Failed to create organization invite audit event');
}
return created;
} catch (error) {
console.error('createOrganizationInviteAudit error:', error);
throw new Error(
`Failed to create organization invite audit event: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function revokeActiveOrganizationInvitesByEmails(
organizationId: string,
emails: string[],
revokedByProfileId: string
): Promise<TOrganizationInvite[]> {
if (emails.length === 0) {
return [];
}
try {
const updated = await db
.update(schema.organizationInvite)
.set({
isRevoked: true,
revokedByProfileId,
revokedAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
})
.where(
and(
eq(schema.organizationInvite.organizationId, organizationId),
inArray(schema.organizationInvite.email, emails),
eq(schema.organizationInvite.isRevoked, false),
isNull(schema.organizationInvite.acceptedAt)
)
)
.returning();
return updated;
} catch (error) {
console.error('revokeExistingOrganizationInvites error:', error);
throw new Error(
`Failed to revoke existing organization invites: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export type TOrganizationInviteTokenData = {
invite: TOrganizationInvite;
organization: {
id: string;
name: string;
siteName: string;
};
};
export async function getOrganizationInviteByTokenHash(
tokenHash: string
): Promise<TOrganizationInviteTokenData | null> {
try {
const [result] = await db
.select({
invite: schema.organizationInvite,
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName
}
})
.from(schema.organizationInvite)
.innerJoin(schema.organization, eq(schema.organizationInvite.organizationId, schema.organization.id))
.where(eq(schema.organizationInvite.tokenHash, tokenHash))
.limit(1);
if (!result) {
return null;
}
return {
...result,
organization: {
...result.organization,
siteName: result.organization.siteName ?? ''
}
};
} catch (error) {
console.error('getOrganizationInviteByTokenHash error:', error);
throw new Error(
`Failed to get organization invite by token: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function markOrganizationInviteAccepted(
inviteId: string,
profileId: string
): Promise<TOrganizationInvite | null> {
try {
const [updated] = await db
.update(schema.organizationInvite)
.set({
acceptedAt: new Date().toISOString(),
acceptedByProfileId: profileId,
updatedAt: new Date().toISOString()
})
.where(and(eq(schema.organizationInvite.id, inviteId), isNull(schema.organizationInvite.acceptedAt)))
.returning();
return updated || null;
} catch (error) {
console.error('markOrganizationInviteAccepted error:', error);
throw new Error(
`Failed to mark organization invite accepted: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Checks whether the given email has at least one active (non-revoked,
* non-accepted, non-expired) organization invite in the specified org.
*/
export async function hasActiveOrganizationInviteForEmail(organizationId: string, email: string): Promise<boolean> {
try {
const normalizedEmail = email.toLowerCase().trim();
const [result] = await db
.select({ id: schema.organizationInvite.id })
.from(schema.organizationInvite)
.where(
and(
eq(schema.organizationInvite.organizationId, organizationId),
eq(schema.organizationInvite.email, normalizedEmail),
eq(schema.organizationInvite.isRevoked, false),
isNull(schema.organizationInvite.acceptedAt),
gt(schema.organizationInvite.expiresAt, sql`NOW()`)
)
)
.limit(1);
return !!result;
} catch (error) {
console.error('hasActiveOrganizationInviteForEmail error:', error);
return false;
}
}
@@ -244,19 +244,19 @@ export const isUserOrgAdmin = async (orgId: string, profileId: string): Promise<
};
/**
* Checks if a user is a member of an organization (any role)
* Gets a user's role in an organization
* @param orgId Organization ID
* @param profileId Profile ID to check
* @returns True if user is a member, false otherwise
* @returns Role ID if user is a member, null otherwise
*/
export const isUserOrgMember = async (orgId: string, profileId: string): Promise<boolean> => {
export const getUserOrgRole = async (orgId: string, profileId: string): Promise<number | null> => {
const result = await db
.select({ roleId: schema.organizationmember.roleId })
.from(schema.organizationmember)
.where(and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.profileId, profileId)))
.limit(1);
return result.length > 0;
return result.length > 0 ? Number(result[0].roleId) : null;
};
/**
@@ -320,7 +320,7 @@ export const getOrganizationTeam = async (orgId: string) => {
};
/**
* Gets organization audience (students who are participants in any course)
* Gets organization audience (all organization members with student role)
* @param orgId Organization ID
* @returns Array of student profiles
*/
@@ -333,10 +333,11 @@ export const getOrganizationAudience = async (orgId: string) => {
avatarUrl: schema.profile.avatarUrl,
createdAt: schema.profile.createdAt
})
.from(schema.profile)
.innerJoin(schema.groupmember, eq(schema.profile.id, schema.groupmember.profileId))
.innerJoin(schema.group, eq(schema.groupmember.groupId, schema.group.id))
.where(and(eq(schema.group.organizationId, orgId), eq(schema.groupmember.roleId, ROLE.STUDENT)));
.from(schema.organizationmember)
.innerJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
.where(
and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.roleId, ROLE.STUDENT))
);
return result.map((profile) => ({
id: profile.id,
@@ -435,6 +436,101 @@ export const getOrganizations = async (filters?: {
}));
};
/**
* Gets the first (and typically only) organization - used for self-hosted single-org mode
* @returns First organization by createdAt, or null if none exist
*/
export const getFirstOrganization = async (): Promise<TOrganization | null> => {
const [organization] = await db.select().from(schema.organization).orderBy(schema.organization.createdAt).limit(1);
return organization || null;
};
/**
* Gets the first organization with plans - for self-hosted single-org mode
* @returns First organization with plans (by createdAt), or null if none exist
*/
export const getFirstOrganizationWithPlans = async (): Promise<
(TOrganization & { plans: Array<OrganizationPlan> }) | null
> => {
// Get the first organization ID (by createdAt) - avoid limit(1) on joined result
// which would discard plan rows when an org has multiple plans
const [firstOrg] = await db
.select({ id: schema.organization.id })
.from(schema.organization)
.orderBy(schema.organization.createdAt)
.limit(1);
if (!firstOrg) return null;
const result = await db
.select({
organization: schema.organization,
plan: {
planName: schema.organizationPlan.planName,
isActive: schema.organizationPlan.isActive,
provider: schema.organizationPlan.provider,
subscriptionId: schema.organizationPlan.subscriptionId,
customerId: sql`organization_plan.payload->>'customerId'`.as('customerId')
}
})
.from(schema.organization)
.leftJoin(schema.organizationPlan, eq(schema.organization.id, schema.organizationPlan.orgId))
.where(eq(schema.organization.id, firstOrg.id));
const organizationMap = new Map<
string,
{
organization: typeof schema.organization.$inferSelect;
plans: Array<OrganizationPlan>;
}
>();
for (const row of result) {
const orgId = row.organization.id;
if (!organizationMap.has(orgId)) {
organizationMap.set(orgId, {
organization: row.organization,
plans: []
});
}
const orgData = organizationMap.get(orgId)!;
if (
row.plan &&
(row.plan.planName !== null ||
row.plan.isActive !== null ||
row.plan.provider !== null ||
row.plan.subscriptionId !== null)
) {
orgData.plans.push({
planName: row.plan.planName,
isActive: row.plan.isActive,
provider: row.plan.provider,
subscriptionId: row.plan.subscriptionId,
customerId: row.plan.customerId as string | null
});
}
}
const first = Array.from(organizationMap.values())[0];
return first
? ({
...(first.organization as TOrganization),
plans: first.plans
} as TOrganization & { plans: Array<OrganizationPlan> })
: null;
};
/**
* Gets the number of organizations - used for self-hosted to block org creation when org exists
* @returns Count of organizations
*/
export const getOrganizationCount = async (): Promise<number> => {
const [result] = await db.select({ count: sql<number>`count(*)::int`.as('count') }).from(schema.organization);
return result?.count ?? 0;
};
/**
* Creates a new organization plan
* @param data Organization plan creation data
@@ -490,15 +586,65 @@ export const cancelOrganizationPlan = async (
/**
* Updates an organization
* @param id Organization ID
* @param data Partial organization data to update
* @param data Partial organization data to update. When `settings` is provided, it is deep-merged with existing settings.
* @returns Updated organization record
*/
export const updateOrganization = async (id: string, data: Partial<TOrganization>): Promise<TOrganization> => {
let setData = { ...data };
if (data.settings !== undefined) {
const [existing] = await db
.select({ settings: schema.organization.settings })
.from(schema.organization)
.where(eq(schema.organization.id, id))
.limit(1);
const existingSettings = (existing?.settings as Record<string, unknown>) ?? {};
const newSettings = data.settings as Record<string, unknown>;
const mergedSettings: Record<string, unknown> = { ...existingSettings };
for (const key of Object.keys(newSettings ?? {})) {
const existingVal = existingSettings[key];
const newVal = newSettings[key];
if (newVal !== undefined) {
if (
existingVal &&
typeof existingVal === 'object' &&
!Array.isArray(existingVal) &&
newVal &&
typeof newVal === 'object' &&
!Array.isArray(newVal)
) {
mergedSettings[key] = { ...(existingVal as Record<string, unknown>), ...(newVal as Record<string, unknown>) };
} else {
mergedSettings[key] = newVal;
}
}
}
setData = { ...setData, settings: mergedSettings as TOrganization['settings'] };
}
const [organization] = await db
.update(schema.organization)
.set(data)
.set(setData)
.where(eq(schema.organization.id, id))
.returning();
return organization;
};
/**
* Get organization plan status for SSO entitlement check
* @param orgId Organization ID
* @returns Array of plan records
*/
export const getOrganizationPlanStatus = async (orgId: string) => {
const result = await db
.select({
planName: schema.organizationPlan.planName,
isActive: schema.organizationPlan.isActive
})
.from(schema.organizationPlan)
.where(eq(schema.organizationPlan.orgId, orgId));
return result;
};
@@ -0,0 +1,42 @@
import * as schema from '@db/schema';
import type { TNewQuiz, TQuiz } from '@db/types';
import { db } from '@db/drizzle';
import { eq } from 'drizzle-orm';
export async function getQuizzesByOrganizationId(orgId: string): Promise<TQuiz[]> {
const quizzes = await db
.select()
.from(schema.quiz)
.where(eq(schema.quiz.organizationId, orgId))
.orderBy(schema.quiz.updatedAt);
return quizzes;
}
export async function getQuizById(quizId: string): Promise<TQuiz | null> {
const [quiz] = await db.select().from(schema.quiz).where(eq(schema.quiz.id, quizId)).limit(1);
return quiz || null;
}
export async function createQuiz(data: TNewQuiz): Promise<TQuiz> {
const [quiz] = await db.insert(schema.quiz).values(data).returning();
return quiz;
}
export async function updateQuiz(quizId: string, data: Partial<TNewQuiz>): Promise<TQuiz> {
const [quiz] = await db
.update(schema.quiz)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(schema.quiz.id, quizId))
.returning();
return quiz;
}
export async function deleteQuiz(quizId: string): Promise<void> {
await db.delete(schema.quiz).where(eq(schema.quiz.id, quizId));
}
+116
View File
@@ -0,0 +1,116 @@
import * as schema from '@db/schema';
import { and, eq } from 'drizzle-orm';
import { db } from '@db/drizzle';
import type {
TNewOrganizationAuthPolicy,
TNewOrganizationSsoConfig,
TOrganizationAuthPolicy,
TOrganizationSsoConfig
} from '@db/types';
// Organization Auth Policy Queries
export const getOrganizationAuthPolicy = async (orgId: string): Promise<TOrganizationAuthPolicy | null> => {
const [policy] = await db
.select()
.from(schema.organizationAuthPolicy)
.where(eq(schema.organizationAuthPolicy.organizationId, orgId))
.limit(1);
return policy || null;
};
export const createOrganizationAuthPolicy = async (
data: TNewOrganizationAuthPolicy
): Promise<TOrganizationAuthPolicy> => {
const [policy] = await db.insert(schema.organizationAuthPolicy).values(data).returning();
return policy;
};
export const updateOrganizationAuthPolicy = async (
orgId: string,
data: Partial<TOrganizationAuthPolicy>
): Promise<TOrganizationAuthPolicy | null> => {
const [policy] = await db
.update(schema.organizationAuthPolicy)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(schema.organizationAuthPolicy.organizationId, orgId))
.returning();
return policy || null;
};
export const deleteOrganizationAuthPolicy = async (orgId: string): Promise<void> => {
await db.delete(schema.organizationAuthPolicy).where(eq(schema.organizationAuthPolicy.organizationId, orgId));
};
// Organization SSO Config Queries
export const getOrganizationSsoConfig = async (orgId: string): Promise<TOrganizationSsoConfig | null> => {
const [config] = await db
.select()
.from(schema.organizationSsoConfig)
.where(eq(schema.organizationSsoConfig.organizationId, orgId))
.limit(1);
return config || null;
};
export const getOrganizationSsoConfigByDomain = async (domain: string): Promise<TOrganizationSsoConfig | null> => {
const [config] = await db
.select()
.from(schema.organizationSsoConfig)
.where(and(eq(schema.organizationSsoConfig.domain, domain), eq(schema.organizationSsoConfig.isActive, true)))
.limit(1);
return config || null;
};
export const createOrganizationSsoConfig = async (data: TNewOrganizationSsoConfig): Promise<TOrganizationSsoConfig> => {
const [config] = await db.insert(schema.organizationSsoConfig).values(data).returning();
return config;
};
export const updateOrganizationSsoConfig = async (
orgId: string,
data: Partial<TOrganizationSsoConfig>
): Promise<TOrganizationSsoConfig | null> => {
const [config] = await db
.update(schema.organizationSsoConfig)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(schema.organizationSsoConfig.organizationId, orgId))
.returning();
return config || null;
};
export const deleteOrganizationSsoConfig = async (orgId: string): Promise<TOrganizationSsoConfig | null> => {
const [config] = await db
.delete(schema.organizationSsoConfig)
.where(eq(schema.organizationSsoConfig.organizationId, orgId))
.returning();
return config || null;
};
// Combined query: Get SSO info with policy
export const getOrganizationSsoInfo = async (orgId: string) => {
const [result] = await db
.select({
config: schema.organizationSsoConfig,
policy: schema.organizationAuthPolicy
})
.from(schema.organizationSsoConfig)
.leftJoin(
schema.organizationAuthPolicy,
eq(schema.organizationSsoConfig.organizationId, schema.organizationAuthPolicy.organizationId)
)
.where(eq(schema.organizationSsoConfig.organizationId, orgId))
.limit(1);
return result || null;
};
@@ -0,0 +1,81 @@
import * as schema from '@db/schema';
import { eq } from 'drizzle-orm';
import { db } from '@db/drizzle';
import type { TNewOrganizationTokenAuth, TOrganizationTokenAuth } from '@db/types';
/** All active token auth configs (org id + secret). For server-side verification only. */
export const getAllActiveTokenAuth = async (): Promise<{ organizationId: string; signingSecret: string }[]> => {
try {
const rows = await db
.select({
organizationId: schema.organizationTokenAuth.organizationId,
signingSecret: schema.organizationTokenAuth.signingSecret
})
.from(schema.organizationTokenAuth)
.where(eq(schema.organizationTokenAuth.isActive, true));
return rows;
} catch (error) {
console.error('getAllActiveTokenAuth error:', error);
throw new Error('Failed to get active token auth configs');
}
};
export const getTokenAuthByOrgId = async (orgId: string): Promise<TOrganizationTokenAuth | null> => {
try {
const [row] = await db
.select()
.from(schema.organizationTokenAuth)
.where(eq(schema.organizationTokenAuth.organizationId, orgId))
.limit(1);
return row || null;
} catch (error) {
console.error('getTokenAuthByOrgId error:', error);
throw new Error('Failed to get token auth by org id');
}
};
export const createTokenAuth = async (data: TNewOrganizationTokenAuth): Promise<TOrganizationTokenAuth> => {
try {
const [row] = await db.insert(schema.organizationTokenAuth).values(data).returning();
if (!row) {
throw new Error('Failed to create token auth');
}
return row;
} catch (error) {
console.error('createTokenAuth error:', error);
throw new Error('Failed to create token auth');
}
};
export const updateTokenAuth = async (
orgId: string,
data: Partial<TOrganizationTokenAuth>
): Promise<TOrganizationTokenAuth | null> => {
try {
const [row] = await db
.update(schema.organizationTokenAuth)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(schema.organizationTokenAuth.organizationId, orgId))
.returning();
return row || null;
} catch (error) {
console.error('updateTokenAuth error:', error);
throw new Error('Failed to update token auth');
}
};
export const deleteTokenAuth = async (orgId: string): Promise<void> => {
try {
await db.delete(schema.organizationTokenAuth).where(eq(schema.organizationTokenAuth.organizationId, orgId));
} catch (error) {
console.error('deleteTokenAuth error:', error);
throw new Error('Failed to delete token auth');
}
};
@@ -0,0 +1 @@
export * from './submission';
@@ -0,0 +1,350 @@
import * as schema from '@db/schema';
import type { TNewQuestionAnswer, TNewSubmission, TQuestionAnswer, TSubmission } from '@db/types';
import { and, db, eq, inArray } from '@db/drizzle';
/**
* Gets a submission by ID
* @param submissionId Submission ID
* @returns Submission or null
*/
export async function getSubmissionById(submissionId: string): Promise<TSubmission | null> {
try {
const [submission] = await db
.select()
.from(schema.submission)
.where(eq(schema.submission.id, submissionId))
.limit(1);
return submission || null;
} catch (error) {
console.error('getSubmissionById error:', error);
throw new Error(
`Failed to get submission by ID "${submissionId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Gets submissions by course ID with full related data (answers, profile, etc.)
* @param courseId Course ID
* @param exerciseId Optional exercise ID filter
* @param submittedBy Optional group member ID filter
* @returns Array of submissions with exercise, groupmember, profile, and answers
*/
export async function getSubmissionsByCourseIdWithDetails(courseId: string, exerciseId?: string, submittedBy?: string) {
try {
// Build conditions
const conditions = [eq(schema.submission.courseId, courseId)];
if (exerciseId) {
conditions.push(eq(schema.submission.exerciseId, exerciseId));
}
if (submittedBy) {
conditions.push(eq(schema.submission.submittedBy, submittedBy));
}
// Get submissions with relations
const submissions = await db
.select({
submission: schema.submission,
groupmember: schema.groupmember,
profile: schema.profile
})
.from(schema.submission)
.leftJoin(schema.groupmember, eq(schema.submission.submittedBy, schema.groupmember.id))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(and(...conditions));
// Get all question answers for these submissions
const submissionIds = submissions.map((s) => s.submission.id);
const questionAnswers =
submissionIds.length > 0
? await db
.select()
.from(schema.questionAnswer)
.where(inArray(schema.questionAnswer.submissionId, submissionIds))
: [];
// Group data by submission
const result = submissions.map((row) => {
const submissionAnswers = questionAnswers.filter((qa) => qa.submissionId === row.submission.id);
return {
...row.submission,
groupmember: row.groupmember
? {
...row.groupmember,
profile: row.profile
}
: null,
answers: submissionAnswers
};
});
return result;
} catch (error) {
console.error('getSubmissionsByCourseIdWithDetails error:', error);
throw new Error(
`Failed to get submissions by course ID with details "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Gets submissions for grading with all related data
* @param courseId Course ID
* @returns Array of submissions with exercise, lesson, groupmember, profile, questions, and answers
*/
export async function getSubmissionsForGrading(courseId: string) {
try {
// First, get all submissions with basic relations
const submissions = await db
.select({
submission: schema.submission,
exercise: schema.exercise,
lesson: schema.lesson,
groupmember: schema.groupmember,
profile: schema.profile
})
.from(schema.submission)
.innerJoin(schema.exercise, eq(schema.submission.exerciseId, schema.exercise.id))
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.leftJoin(schema.groupmember, eq(schema.submission.submittedBy, schema.groupmember.id))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(eq(schema.submission.courseId, courseId));
// Get all question answers for these submissions
const submissionIds = submissions.map((s) => s.submission.id);
const questionAnswers =
submissionIds.length > 0
? await db
.select()
.from(schema.questionAnswer)
.where(inArray(schema.questionAnswer.submissionId, submissionIds))
: [];
// Get all questions for the exercises
const exerciseIds = [...new Set(submissions.map((s) => s.exercise.id))];
const questions =
exerciseIds.length > 0
? await db.select().from(schema.question).where(inArray(schema.question.exerciseId, exerciseIds))
: [];
// Get all options for the questions
const questionIds = [...new Set(questions.map((q) => q.id))];
const options =
questionIds.length > 0
? await db.select().from(schema.option).where(inArray(schema.option.questionId, questionIds))
: [];
// Group data by submission
const result = submissions.map((row) => {
const submissionAnswers = questionAnswers.filter((qa) => qa.submissionId === row.submission.id);
const exerciseQuestions = questions.filter((q) => q.exerciseId === row.exercise.id);
const questionsWithOptions = exerciseQuestions.map((q) => ({
...q,
options: options.filter((o) => o.questionId === q.id)
}));
return {
...row.submission,
exercise: {
...row.exercise,
questions: questionsWithOptions
},
lesson: row.lesson,
groupmember: row.groupmember
? {
...row.groupmember,
profile: row.profile
}
: null,
answers: submissionAnswers
};
});
return result;
} catch (error) {
console.error('getSubmissionsForGrading error:', error);
throw new Error(
`Failed to get submissions for grading "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Creates a new submission
* @param data Submission data
* @returns Created submission
*/
export async function createSubmission(data: TNewSubmission): Promise<TSubmission> {
try {
const [submission] = await db.insert(schema.submission).values(data).returning();
if (!submission) {
throw new Error('Failed to create submission');
}
return submission;
} catch (error) {
console.error('createSubmission error:', error);
throw new Error(`Failed to create submission: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Updates a submission
* @param submissionId Submission ID
* @param data Partial submission data
* @returns Updated submission or null
*/
export async function updateSubmission(submissionId: string, data: Partial<TSubmission>): Promise<TSubmission | null> {
try {
const [updated] = await db
.update(schema.submission)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(schema.submission.id, submissionId))
.returning();
return updated || null;
} catch (error) {
console.error('updateSubmission error:', error);
throw new Error(
`Failed to update submission "${submissionId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Deletes a submission
* @param submissionId Submission ID
* @returns Deleted submission or null
*/
export async function deleteSubmission(submissionId: string): Promise<TSubmission | null> {
try {
const [deleted] = await db.delete(schema.submission).where(eq(schema.submission.id, submissionId)).returning();
return deleted || null;
} catch (error) {
console.error('deleteSubmission error:', error);
throw new Error(
`Failed to delete submission "${submissionId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Gets question answers for a submission
* @param submissionId Submission ID
* @returns Array of question answers
*/
export async function getQuestionAnswersBySubmissionId(submissionId: string): Promise<TQuestionAnswer[]> {
try {
return db.select().from(schema.questionAnswer).where(eq(schema.questionAnswer.submissionId, submissionId));
} catch (error) {
console.error('getQuestionAnswersBySubmissionId error:', error);
throw new Error(
`Failed to get question answers for submission "${submissionId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Creates or updates a question answer
* @param data Question answer data
* @returns Created or updated question answer
*/
export async function upsertQuestionAnswer(data: TNewQuestionAnswer): Promise<TQuestionAnswer> {
try {
// Note: This assumes there's a unique constraint on submissionId + questionId
// If not, we may need to check existence first and update/insert accordingly
const [answer] = await db.insert(schema.questionAnswer).values(data).returning();
if (!answer) {
throw new Error('Failed to upsert question answer');
}
return answer;
} catch (error) {
console.error('upsertQuestionAnswer error:', error);
throw new Error(`Failed to upsert question answer: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Updates a question answer
* @param submissionId Submission ID
* @param questionId Question ID
* @param data Partial question answer data
* @returns Updated question answer or null
*/
export async function updateQuestionAnswer(
submissionId: string,
questionId: number,
data: Partial<TQuestionAnswer>
): Promise<TQuestionAnswer | null> {
try {
const [updated] = await db
.update(schema.questionAnswer)
.set(data)
.where(
and(eq(schema.questionAnswer.submissionId, submissionId), eq(schema.questionAnswer.questionId, questionId))
)
.returning();
return updated || null;
} catch (error) {
console.error('updateQuestionAnswer error:', error);
throw new Error(`Failed to update question answer: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export type TGradesUpdateItem = { questionId: number; points: number };
/**
* Updates all question answer points and submission total/feedback in a single transaction.
* Optionally updates statusId and gradingState when provided.
*/
export async function updateSubmissionGrades(
submissionId: string,
data: {
answers: TGradesUpdateItem[];
total: number;
feedback?: string;
statusId?: number;
gradingState?: string;
}
): Promise<TSubmission | null> {
try {
return await db.transaction(async (tx) => {
for (const { questionId, points } of data.answers) {
await tx
.update(schema.questionAnswer)
.set({ point: points })
.where(
and(eq(schema.questionAnswer.submissionId, submissionId), eq(schema.questionAnswer.questionId, questionId))
);
}
const submissionData: Partial<TSubmission> = {
total: data.total,
updatedAt: new Date().toISOString()
};
if (data.feedback !== undefined) {
submissionData.feedback = data.feedback;
}
if (data.statusId !== undefined) {
submissionData.statusId = data.statusId;
}
if (data.gradingState !== undefined) {
submissionData.gradingState = data.gradingState;
}
const [updated] = await tx
.update(schema.submission)
.set(submissionData)
.where(eq(schema.submission.id, submissionId))
.returning();
return updated || null;
});
} catch (error) {
console.error('updateSubmissionGrades error:', error);
throw new Error(
`Failed to update submission grades "${submissionId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
+1
View File
@@ -0,0 +1 @@
export * from './tag';
+399
View File
@@ -0,0 +1,399 @@
import type { TNewTag, TNewTagGroup, TTag, TTagAssignment, TTagGroup } from '@db/types';
import { and, asc, eq, inArray, sql } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
export interface TTagWithCourseCount extends TTag {
courseCount: number;
}
export interface TTagGroupWithTags extends TTagGroup {
tags: TTagWithCourseCount[];
}
export interface TCourseTagPreview {
id: string;
name: string;
slug: string;
color: string;
}
interface GetTagGroupsWithTagsOptions {
onlyPublishedCourses?: boolean;
}
export function slugifyTagValue(value: string): string {
const slug = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return slug || 'tag';
}
async function ensureUniqueSlug(baseSlug: string, exists: (slug: string) => Promise<boolean>): Promise<string> {
let suffix = 0;
let candidate = baseSlug;
while (await exists(candidate)) {
suffix += 1;
candidate = `${baseSlug}-${suffix}`;
}
return candidate;
}
export async function generateUniqueTagGroupSlug(orgId: string, name: string): Promise<string> {
try {
const baseSlug = slugifyTagValue(name);
return ensureUniqueSlug(baseSlug, async (slug) => {
const existing = await findTagGroupBySlug(orgId, slug);
return Boolean(existing);
});
} catch (error) {
console.error('generateUniqueTagGroupSlug error:', error);
throw new Error('Failed to generate unique tag group slug');
}
}
export async function generateUniqueTagSlug(orgId: string, name: string): Promise<string> {
try {
const baseSlug = slugifyTagValue(name);
return ensureUniqueSlug(baseSlug, async (slug) => {
const existing = await findTagBySlug(orgId, slug);
return Boolean(existing);
});
} catch (error) {
console.error('generateUniqueTagSlug error:', error);
throw new Error('Failed to generate unique tag slug');
}
}
export async function findTagGroupBySlug(orgId: string, slug: string): Promise<TTagGroup | null> {
try {
const [result] = await db
.select()
.from(schema.tagGroup)
.where(and(eq(schema.tagGroup.organizationId, orgId), eq(schema.tagGroup.slug, slug)))
.limit(1);
return result ?? null;
} catch (error) {
console.error('findTagGroupBySlug error:', error);
throw new Error('Failed to find tag group by slug');
}
}
export async function findTagBySlug(orgId: string, slug: string): Promise<TTag | null> {
try {
const [result] = await db
.select()
.from(schema.tag)
.where(and(eq(schema.tag.organizationId, orgId), eq(schema.tag.slug, slug)))
.limit(1);
return result ?? null;
} catch (error) {
console.error('findTagBySlug error:', error);
throw new Error('Failed to find tag by slug');
}
}
export async function getTagGroupById(orgId: string, groupId: string): Promise<TTagGroup | null> {
try {
const [result] = await db
.select()
.from(schema.tagGroup)
.where(and(eq(schema.tagGroup.organizationId, orgId), eq(schema.tagGroup.id, groupId)))
.limit(1);
return result ?? null;
} catch (error) {
console.error('getTagGroupById error:', error);
throw new Error('Failed to get tag group by id');
}
}
export async function getTagById(orgId: string, tagId: string): Promise<TTag | null> {
try {
const [result] = await db
.select()
.from(schema.tag)
.where(and(eq(schema.tag.organizationId, orgId), eq(schema.tag.id, tagId)))
.limit(1);
return result ?? null;
} catch (error) {
console.error('getTagById error:', error);
throw new Error('Failed to get tag by id');
}
}
export async function getTagsByIds(orgId: string, tagIds: string[]): Promise<TTag[]> {
try {
if (tagIds.length === 0) {
return [];
}
return db
.select()
.from(schema.tag)
.where(and(eq(schema.tag.organizationId, orgId), inArray(schema.tag.id, tagIds)));
} catch (error) {
console.error('getTagsByIds error:', error);
throw new Error('Failed to get tags by ids');
}
}
export async function createTagGroup(data: TNewTagGroup): Promise<TTagGroup> {
try {
const [result] = await db.insert(schema.tagGroup).values(data).returning();
return result;
} catch (error) {
console.error('createTagGroup error:', error);
throw new Error('Failed to create tag group');
}
}
export async function updateTagGroup(
orgId: string,
groupId: string,
data: Partial<TTagGroup>
): Promise<TTagGroup | null> {
try {
const [result] = await db
.update(schema.tagGroup)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(and(eq(schema.tagGroup.organizationId, orgId), eq(schema.tagGroup.id, groupId)))
.returning();
return result ?? null;
} catch (error) {
console.error('updateTagGroup error:', error);
throw new Error('Failed to update tag group');
}
}
export async function createTag(data: TNewTag): Promise<TTag> {
try {
const [result] = await db.insert(schema.tag).values(data).returning();
return result;
} catch (error) {
console.error('createTag error:', error);
throw new Error('Failed to create tag');
}
}
export async function updateTag(orgId: string, tagId: string, data: Partial<TTag>): Promise<TTag | null> {
try {
const [result] = await db
.update(schema.tag)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(and(eq(schema.tag.organizationId, orgId), eq(schema.tag.id, tagId)))
.returning();
return result ?? null;
} catch (error) {
console.error('updateTag error:', error);
throw new Error('Failed to update tag');
}
}
export async function getTagGroupsWithTags(
orgId: string,
options: GetTagGroupsWithTagsOptions = {}
): Promise<TTagGroupWithTags[]> {
try {
const { onlyPublishedCourses = false } = options;
const courseJoinConditions = [
eq(schema.tagAssignment.courseId, schema.course.id),
eq(schema.course.status, 'ACTIVE')
];
if (onlyPublishedCourses) {
courseJoinConditions.push(eq(schema.course.isPublished, true));
}
const [groups, tags] = await Promise.all([
db
.select()
.from(schema.tagGroup)
.where(eq(schema.tagGroup.organizationId, orgId))
.orderBy(asc(schema.tagGroup.order), asc(schema.tagGroup.name)),
db
.select({
tag: schema.tag,
courseCount: sql<number>`COUNT(DISTINCT ${schema.course.id})`.as('course_count')
})
.from(schema.tag)
.leftJoin(schema.tagAssignment, eq(schema.tag.id, schema.tagAssignment.tagId))
.leftJoin(schema.course, and(...courseJoinConditions))
.where(eq(schema.tag.organizationId, orgId))
.groupBy(schema.tag.id)
.orderBy(asc(schema.tag.name))
]);
const groupedTags = new Map<string, TTagWithCourseCount[]>();
for (const row of tags) {
const tagWithCount: TTagWithCourseCount = {
...row.tag,
courseCount: Number(row.courseCount)
};
const existing = groupedTags.get(row.tag.groupId) ?? [];
existing.push(tagWithCount);
groupedTags.set(row.tag.groupId, existing);
}
return groups.map((group) => ({
...group,
tags: groupedTags.get(group.id) ?? []
}));
} catch (error) {
console.error('getTagGroupsWithTags error:', error);
throw new Error('Failed to get tag groups with tags');
}
}
export async function getCourseIdsByTagSlugs(orgId: string, tagSlugs: string[]): Promise<string[]> {
try {
const normalizedTagSlugs = Array.from(new Set(tagSlugs.map((slug) => slug.trim()).filter(Boolean)));
if (normalizedTagSlugs.length === 0) {
return [];
}
const rows = await db
.selectDistinct({
courseId: schema.tagAssignment.courseId
})
.from(schema.tagAssignment)
.innerJoin(schema.tag, eq(schema.tagAssignment.tagId, schema.tag.id))
.innerJoin(schema.course, eq(schema.tagAssignment.courseId, schema.course.id))
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.where(
and(
eq(schema.tag.organizationId, orgId),
eq(schema.group.organizationId, orgId),
eq(schema.course.status, 'ACTIVE'),
inArray(schema.tag.slug, normalizedTagSlugs)
)
);
return rows.map((row) => row.courseId);
} catch (error) {
console.error('getCourseIdsByTagSlugs error:', error);
throw new Error('Failed to get course ids by tag slugs');
}
}
export async function getCourseOrganizationId(courseId: string): Promise<string | null> {
try {
const [row] = await db
.select({
organizationId: schema.group.organizationId
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.where(eq(schema.course.id, courseId))
.limit(1);
return row?.organizationId ?? null;
} catch (error) {
console.error('getCourseOrganizationId error:', error);
throw new Error('Failed to get course organization id');
}
}
export async function getCourseTagsForOrganization(orgId: string, courseId: string): Promise<TTag[]> {
try {
const rows = await db
.select({ tag: schema.tag })
.from(schema.tagAssignment)
.innerJoin(schema.tag, eq(schema.tagAssignment.tagId, schema.tag.id))
.where(and(eq(schema.tag.organizationId, orgId), eq(schema.tagAssignment.courseId, courseId)))
.orderBy(asc(schema.tag.name));
return rows.map((row) => row.tag);
} catch (error) {
console.error('getCourseTagsForOrganization error:', error);
throw new Error('Failed to get course tags for organization');
}
}
export async function getCourseTagsByCourseIdsForOrganization(
orgId: string,
courseIds: string[]
): Promise<Record<string, TCourseTagPreview[]>> {
try {
const normalizedCourseIds = Array.from(new Set(courseIds.map((id) => id.trim()).filter(Boolean)));
if (normalizedCourseIds.length === 0) {
return {};
}
const rows = await db
.select({
courseId: schema.tagAssignment.courseId,
tagId: schema.tag.id,
tagName: schema.tag.name,
tagSlug: schema.tag.slug,
tagColor: schema.tag.color
})
.from(schema.tagAssignment)
.innerJoin(schema.tag, eq(schema.tagAssignment.tagId, schema.tag.id))
.where(and(eq(schema.tag.organizationId, orgId), inArray(schema.tagAssignment.courseId, normalizedCourseIds)))
.orderBy(asc(schema.tag.name));
const grouped: Record<string, TCourseTagPreview[]> = {};
for (const row of rows) {
grouped[row.courseId] ??= [];
grouped[row.courseId].push({
id: row.tagId,
name: row.tagName,
slug: row.tagSlug,
color: row.tagColor
});
}
return grouped;
} catch (error) {
console.error('getCourseTagsByCourseIdsForOrganization error:', error);
throw new Error('Failed to get course tags by course ids for organization');
}
}
export async function replaceCourseTagAssignments(courseId: string, tagIds: string[]): Promise<TTagAssignment[]> {
try {
const normalizedTagIds = Array.from(new Set(tagIds));
return db.transaction(async (tx) => {
await tx.delete(schema.tagAssignment).where(eq(schema.tagAssignment.courseId, courseId));
if (normalizedTagIds.length === 0) {
return [];
}
return tx
.insert(schema.tagAssignment)
.values(
normalizedTagIds.map((tagId) => ({
courseId,
tagId
}))
)
.returning();
});
} catch (error) {
console.error('replaceCourseTagAssignments error:', error);
throw new Error('Failed to replace course tag assignments');
}
}