From 44fd50c2474ee6c615cef6a4f3c6228c47f6e126 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Tue, 4 Nov 2025 14:58:06 -0800 Subject: [PATCH 01/27] deleted redundant state inside EditCourse --- .../app/(dashboard)/components/EditCourse.tsx | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/packages/frontend/app/(dashboard)/components/EditCourse.tsx b/packages/frontend/app/(dashboard)/components/EditCourse.tsx index 76f98763e..1de0361b7 100644 --- a/packages/frontend/app/(dashboard)/components/EditCourse.tsx +++ b/packages/frontend/app/(dashboard)/components/EditCourse.tsx @@ -34,7 +34,6 @@ const EditCourse: React.FC = ({ }) => { const organizationSettings = useOrganizationSettings(organization.id) const [courseData, setCourseData] = useState() - const [featuresEnabled, setFeaturesEnabled] = useState(false) const { userInfo, setUserInfo } = useUserInfo() const router = useRouter() @@ -76,24 +75,12 @@ const EditCourse: React.FC = ({ }) } - const checkFeaturesDisabled = async () => { - if (user.courses.length === 0) { - setFeaturesEnabled(false) - return - } - - const isUserInCourse = user.courses.find( - (course) => course.course.id === courseId, - ) - - if (isUserInCourse) { - setFeaturesEnabled(true) - } - } + const isUserInCourse = userInfo.courses.find( + (course) => course.course.id === courseId, + ) useEffect(() => { fetchCourseData() - checkFeaturesDisabled() }, []) return courseData ? ( @@ -109,7 +96,7 @@ const EditCourse: React.FC = ({ /> - {featuresEnabled && ( + {isUserInCourse && ( <> From 19152106619011666158e30f6163a210108bf0af Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Tue, 4 Nov 2025 15:46:41 -0800 Subject: [PATCH 02/27] added highlight in coursesSection to the newly created course --- packages/common/index.ts | 5 +++++ .../components/CoursesSectionTableView.tsx | 5 +++++ .../(dashboard)/components/coursesSection.tsx | 19 ++++++++++++------- .../frontend/app/(dashboard)/courses/page.tsx | 9 ++++++++- .../organization/course/add/page.tsx | 8 +++++--- packages/frontend/app/api/index.ts | 3 ++- .../organization/organization.controller.ts | 7 +++++-- 7 files changed, 42 insertions(+), 14 deletions(-) diff --git a/packages/common/index.ts b/packages/common/index.ts index 0285a3ba3..7cfa17eb5 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -1984,6 +1984,11 @@ export type OrganizationProfessor = { userId: number } +export type CreateCourseResponse = { + message: string + courseId: number +} + export class UpdateOrganizationCourseDetailsParams { @IsString() @IsOptional() diff --git a/packages/frontend/app/(dashboard)/components/CoursesSectionTableView.tsx b/packages/frontend/app/(dashboard)/components/CoursesSectionTableView.tsx index 27bf7abe7..ae6bb299a 100644 --- a/packages/frontend/app/(dashboard)/components/CoursesSectionTableView.tsx +++ b/packages/frontend/app/(dashboard)/components/CoursesSectionTableView.tsx @@ -11,10 +11,12 @@ import { useMemo } from 'react' interface CoursesSectionTableViewProps { semesters: SemesterPartial[] + highlightedCourseId?: number } const CoursesSectionTableView: React.FC = ({ semesters, + highlightedCourseId, }) => { const { userInfo, setUserInfo } = useUserInfo() @@ -107,6 +109,9 @@ const CoursesSectionTableView: React.FC = ({ {course.course.sectionGroupName && ( {`${course.course.sectionGroupName}`} )} + {highlightedCourseId === course.course.id && ( + (New) + )} ), }, diff --git a/packages/frontend/app/(dashboard)/components/coursesSection.tsx b/packages/frontend/app/(dashboard)/components/coursesSection.tsx index 8af282005..bc1f06bf0 100644 --- a/packages/frontend/app/(dashboard)/components/coursesSection.tsx +++ b/packages/frontend/app/(dashboard)/components/coursesSection.tsx @@ -11,11 +11,13 @@ import CoursesSectionTableView from './CoursesSectionTableView' interface CoursesSectionProps { semesters: SemesterPartial[] enabledTableView: boolean + highlightedCourseId?: number } const CoursesSection: React.FC = ({ semesters, enabledTableView, + highlightedCourseId, }) => { // For some reason, jdenticon is not working when imported as a module and needs to use require // eslint-disable @typescript-eslint/no-var-requires @@ -55,7 +57,7 @@ const CoursesSection: React.FC = ({ if (semesterA && !semesterB) return -1 if (semesterB && !semesterA) return 1 - /* + /* Note that this is implicitly true at this point, but to avoid TypeScript errors we define the conditional statement: */ @@ -68,13 +70,13 @@ const CoursesSection: React.FC = ({ ? new Date(semesterA.endDate).getTime() : semesterA.startDate ? new Date(semesterA.startDate).getTime() - : -Infinity; + : -Infinity const bTime = semesterB.endDate ? new Date(semesterB.endDate).getTime() : semesterB.startDate ? new Date(semesterB.startDate).getTime() - : -Infinity; - + : -Infinity + const diff = bTime - aTime if (diff === 0) { return a.course.name.localeCompare(b.course.name) @@ -82,14 +84,17 @@ const CoursesSection: React.FC = ({ return diff } } - return a.course.name.localeCompare(b.course.name); + return a.course.name.localeCompare(b.course.name) }) }, [userInfo.courses, semesters]) return (
{enabledTableView ? ( - + ) : (
{sortedCoursesInCardView.map((course, index) => { @@ -112,7 +117,7 @@ const CoursesSection: React.FC = ({ return (
)}
diff --git a/packages/frontend/app/(dashboard)/organization/course/add/page.tsx b/packages/frontend/app/(dashboard)/organization/course/add/page.tsx index e3468795c..ff6875ad9 100644 --- a/packages/frontend/app/(dashboard)/organization/course/add/page.tsx +++ b/packages/frontend/app/(dashboard)/organization/course/add/page.tsx @@ -143,12 +143,14 @@ export default function AddCoursePage(): ReactElement { profIds: profIds, courseSettings: courseFeatures, }) - .then(async () => { - message.success('Course was created') + .then(async (createCourseResponse) => { + message.success(createCourseResponse.message) // need to update userInfo so the course shows up in /courses await userApi.getUser().then((userDetails) => { setUserInfo(userDetails) - router.push('/courses') + router.push( + `/courses?highlightedCourse=${createCourseResponse.courseId}`, + ) }) }) .catch((error) => { diff --git a/packages/frontend/app/api/index.ts b/packages/frontend/app/api/index.ts index 28031576f..91cdb3eaa 100644 --- a/packages/frontend/app/api/index.ts +++ b/packages/frontend/app/api/index.ts @@ -118,6 +118,7 @@ import { UpsertLMSOrganizationParams, UserMailSubscription, LMSSyncDocumentsResult, + CreateCourseResponse, } from '@koh/common' import Axios, { AxiosInstance, Method } from 'axios' import { plainToClass } from 'class-transformer' @@ -1103,7 +1104,7 @@ class APIClient { createCourse: async ( oid: number, body: UpdateOrganizationCourseDetailsParams, - ): Promise => + ): Promise => this.req( 'POST', `/api/v1/organization/${oid}/create_course`, diff --git a/packages/server/src/organization/organization.controller.ts b/packages/server/src/organization/organization.controller.ts index d68fb0b9c..b6ed28af1 100644 --- a/packages/server/src/organization/organization.controller.ts +++ b/packages/server/src/organization/organization.controller.ts @@ -25,6 +25,7 @@ import { COURSE_TIMEZONES, CourseResponse, CourseSettingsRequestBody, + CreateCourseResponse, ERROR_MESSAGES, GetOrganizationResponse, GetOrganizationUserResponse, @@ -298,7 +299,7 @@ export class OrganizationController { @OrgRole() orgRole: OrganizationRole, @Body() courseDetails: UpdateOrganizationCourseDetailsParams, @Res() res: Response, - ): Promise> { + ): Promise> { const orgSettings = await this.organizationService.getOrganizationSettings(oid); if ( @@ -344,9 +345,10 @@ export class OrganizationController { )}`, }); } + let newCourse: CourseModel; await this.dataSource.transaction(async (manager) => { // Create course entity - const newCourse = manager.create(CourseModel, { + newCourse = manager.create(CourseModel, { name: courseDetails.name, coordinator_email: courseDetails.coordinator_email, sectionGroupName: courseDetails.sectionGroupName, @@ -432,6 +434,7 @@ export class OrganizationController { return res.status(status).send({ message: message, + courseId: newCourse.id, }); } From 1b06c2668a0f88d0a3dcf5becb3e2e22364943f0 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Thu, 6 Nov 2025 14:44:43 -0800 Subject: [PATCH 03/27] Backend method for accepting prof invite done mostly. Added new QUERY_PARAMS object for organizing query params throughout codebase (mostly to make it easier to find where a particular query param is used) --- packages/common/index.ts | 35 ++++++ .../app/(dashboard)/course/[cid]/page.tsx | 62 +++++++++- .../frontend/app/(dashboard)/courses/page.tsx | 61 ++++++++-- packages/server/src/course/course.entity.ts | 5 + packages/server/src/course/course.service.ts | 111 +++++++++++++++++- .../server/src/course/prof-invite.entity.ts | 59 ++++++++++ packages/server/src/profile/user.entity.ts | 5 + 7 files changed, 318 insertions(+), 20 deletions(-) create mode 100644 packages/server/src/course/prof-invite.entity.ts diff --git a/packages/common/index.ts b/packages/common/index.ts index 7cfa17eb5..20a167cf5 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -3865,3 +3865,38 @@ export const ERROR_MESSAGES = { `Members with role ${role} are not allowed to delete semesters`, }, } + +/* Common Query Params + Does two things: + - Allows us to easily modify the query params for error messages in 1 spot + - More importantly, it connects the backend with the frontend to make it easier to find where a particular query param is coming from + */ +export const QUERY_PARAMS = { + profInvite: { + // note that some uses of these query params will just check for .startsWith (e.g. .startsWith('prof_invite_')) + error: { + expired: 'prof_invite_expired', + expiresAt: 'expired_at', + maxUsesReached: 'prof_invite_max_uses_reached', + maxUses: 'max_uses', + notFound: 'prof_invite_not_found', + profInviteId: 'pinvite_id', + userNotFound: 'prof_invite_user_not_found', + // It's tempting to want to re-organize this better, but it can make the urls more gross to read (e.g. /courses?error=${QUERY_PARAMS.profInviteError.notFound.queryParam}&${QUERY_PARAMS.profInviteError.notFound.extraParams.profInviteId}=${profInviteId}) + }, + notice: { + adminAlreadyInCourse: 'pi_admin_already_in_course', + adminAcceptedInviteNotConsumed: 'pi_admin_accepted_invite_not_consumed', + inviteAccepted: 'pi_invite_accepted', + }, + }, + queueInvite: { + error: { + notInCourse: 'queue_invite_not_in_course', + inviteNotFound: 'queue_invite_not_found', + courseNotFound: 'queue_invite_course_not_found', + badCourseInviteCode: 'queue_invite_bad_course_invite_code', + }, + }, + // TODO: add the /login redirect query params here. Avoided doing so right now since that would require middleware.ts to import this file and iirc there is errors when you try to do that +} diff --git a/packages/frontend/app/(dashboard)/course/[cid]/page.tsx b/packages/frontend/app/(dashboard)/course/[cid]/page.tsx index 66df1bee3..5e49bbdcd 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/page.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/page.tsx @@ -1,7 +1,7 @@ 'use client' -import { Role } from '@koh/common' -import { Col, Row, Button } from 'antd' +import { QUERY_PARAMS, Role } from '@koh/common' +import { Col, Row, Button, Alert } from 'antd' import { ReactElement, useEffect, useMemo, useState, use } from 'react' import QueueCard from './components/QueueCard' import { useCourseFeatures } from '@/app/hooks/useCourseFeatures' @@ -20,6 +20,7 @@ import TAFacultySchedulePanel from './schedule/components/TASchedulePanel' import StudentSchedulePanel from './schedule/components/StudentSchedulePanel' import { useChatbotContext } from './components/chatbot/ChatbotProvider' import Chatbot from './components/chatbot/Chatbot' +import { useSearchParams } from 'next/navigation' type CoursePageProps = { params: Promise<{ cid: string }> @@ -31,6 +32,8 @@ export default function CoursePage(props: CoursePageProps): ReactElement { const { userInfo } = useUserInfo() const role = getRoleInCourse(userInfo, cid) const { course } = useCourse(cid) + const searchParams = useSearchParams() + const queryParamError = searchParams.get('error') const [createQueueModalOpen, setCreateQueueModalOpen] = useState(false) const courseFeatures = useCourseFeatures(cid) const onlyChatBotEnabled = useMemo( @@ -99,6 +102,59 @@ export default function CoursePage(props: CoursePageProps): ReactElement { md={12} xs={24} > + + {queryParamError && ( + { + switch (queryParamError) { + case QUERY_PARAMS.profInvite.notice + .adminAlreadyInCourse: + return 'You (admin) are already in this course. Professor invite not consumed and still working' + case QUERY_PARAMS.profInvite.notice + .adminAcceptedInviteNotConsumed: + return `Professor invite successfully accepted! You are now a professor inside this course. Since you are an admin, the professor invite was not consumed and still working` + case QUERY_PARAMS.profInvite.notice.inviteAccepted: // TODO: Start the tutorial from here + return ( + <> +

+ Professor invite successfully accepted! + Welcome to your course! +

+

+ You can find a list of video tutorials here: +

+ + + ) + default: + return queryParamError + } + })()} + type="info" + showIcon + closable + /> + )} +

{course?.name} Help Centre @@ -241,7 +297,7 @@ export default function CoursePage(props: CoursePageProps): ReactElement { helpmeQuestionId={helpmeQuestionId} chatbotQuestionType={chatbotQuestionType} setChatbotQuestionType={setChatbotQuestionType} - /* eslint-disable-next-line @typescript-eslint/no-empty-function */ + setIsOpen={() => {}} />

diff --git a/packages/frontend/app/(dashboard)/courses/page.tsx b/packages/frontend/app/(dashboard)/courses/page.tsx index 0f6e00b51..5efbd6015 100644 --- a/packages/frontend/app/(dashboard)/courses/page.tsx +++ b/packages/frontend/app/(dashboard)/courses/page.tsx @@ -10,7 +10,7 @@ import { useSearchParams } from 'next/navigation' import { AppstoreOutlined, BarsOutlined } from '@ant-design/icons' import ArchivedCoursesSection from '../components/ArchivedCoursesSection' import { API } from '@/app/api' -import { SemesterPartial } from '@koh/common' +import { QUERY_PARAMS, SemesterPartial } from '@koh/common' import { useOrganizationSettings } from '@/app/hooks/useOrganizationSettings' import { checkCourseCreatePermissions } from '@/app/utils/generalUtils' @@ -75,18 +75,53 @@ export default function CoursesPage(): ReactElement { { + if (error?.startsWith('prof_invite_')) { + let profInviteMsg = '' + switch (error) { + case QUERY_PARAMS.profInvite.error.expired: + profInviteMsg = `That prof invite has expired at ${searchParams.get(QUERY_PARAMS.profInvite.error.expiresAt)}` + break + case QUERY_PARAMS.profInvite.error.maxUsesReached: + profInviteMsg = `That prof invite has reached the maximum number of uses (${searchParams.get(QUERY_PARAMS.profInvite.error.maxUses)})` + break + case QUERY_PARAMS.profInvite.error.notFound: + profInviteMsg = `That prof invite of ID ${searchParams.get(QUERY_PARAMS.profInvite.error.profInviteId)} has been deleted or does not exist. If you believe this is an error, please contact an admin.` + break + case QUERY_PARAMS.profInvite.error.userNotFound: + profInviteMsg = `User does not exist. Please contact an admin.` + break + default: + profInviteMsg = `An unexpected error occurred: ${error}` + } + return 'Error Accepting Professor Invite: ' + profInviteMsg + } else if (error?.startsWith('queue_invite_')) { + let queueInviteMsg = '' + switch (error) { + case QUERY_PARAMS.queueInvite.error.notInCourse: + queueInviteMsg = + 'You must be a member of that course to join that queue' + break + case QUERY_PARAMS.queueInvite.error.inviteNotFound: + queueInviteMsg = + 'That queue invite has been deleted or does not exist. If you believe this is an error, please contact your professor.' + break + case QUERY_PARAMS.queueInvite.error.courseNotFound: + queueInviteMsg = + 'That course has been deleted or does not exist' + break + case QUERY_PARAMS.queueInvite.error.badCourseInviteCode: + queueInviteMsg = + 'Unable to enroll in course as the course does not have a course invite code set. If you believe this is an error, please contact your professor.' + break + default: + queueInviteMsg = `An unexpected error occurred: ${error}` + } + return 'Error Joining Queue: ' + queueInviteMsg + } else { + return `An unexpected error occurred: ${error}` + } + })()} type="warning" showIcon closable diff --git a/packages/server/src/course/course.entity.ts b/packages/server/src/course/course.entity.ts index 4c126e690..50924a07f 100644 --- a/packages/server/src/course/course.entity.ts +++ b/packages/server/src/course/course.entity.ts @@ -25,6 +25,7 @@ import { UnreadAsyncQuestionModel } from '../asyncQuestion/unread-async-question import { ChatbotDocPdfModel } from '../chatbot/chatbot-doc-pdf.entity'; import { SuperCourseModel } from './super-course.entity'; import { CourseChatbotSettingsModel } from '../chatbot/chatbot-infrastructure-models/course-chatbot-settings.entity'; +import { ProfInviteModel } from './prof-invite.entity'; @Entity('course_model') export class CourseModel extends BaseEntity { @@ -161,4 +162,8 @@ export class CourseModel extends BaseEntity { (courseChatbotSettings) => courseChatbotSettings.course, ) chatbotSettings: CourseChatbotSettingsModel; + + @OneToMany((type) => ProfInviteModel, (profInvite) => profInvite.course) + @Exclude() + profInvites: ProfInviteModel[]; } diff --git a/packages/server/src/course/course.service.ts b/packages/server/src/course/course.service.ts index 1cfeab4b1..01120c75d 100644 --- a/packages/server/src/course/course.service.ts +++ b/packages/server/src/course/course.service.ts @@ -7,6 +7,7 @@ import { GetCourseUserInfoResponse, MailServiceType, OrganizationRole, + QUERY_PARAMS, QueueConfig, QueueTypes, Role, @@ -43,6 +44,7 @@ import { QuestionTypeModel } from 'questionType/question-type.entity'; import { QueueModel } from 'queue/queue.entity'; import { SuperCourseModel } from './super-course.entity'; import { ChatbotDocPdfModel } from 'chatbot/chatbot-doc-pdf.entity'; +import { ProfInviteModel } from './prof-invite.entity'; @Injectable() export class CourseService { @@ -357,7 +359,7 @@ export class CourseService { } } else if (!queueInvite) { // if the queueInvite doesn't exist - return '/courses?err=inviteNotFound'; + return `/courses?err=${QUERY_PARAMS.queueInvite.error.inviteNotFound}`; } else if (queueInvite.willInviteToCourse && courseInviteCode) { // get course const course = await CourseModel.findOne({ @@ -366,10 +368,10 @@ export class CourseService { }, }); if (!course) { - return '/courses?err=courseNotFound'; + return `/courses?err=${QUERY_PARAMS.queueInvite.error.courseNotFound}`; } if (course.courseInviteCode !== courseInviteCode) { - return '/courses?err=badCourseInviteCode'; + return `/courses?err=${QUERY_PARAMS.queueInvite.error.badCourseInviteCode}`; } await this.addStudentToCourse(course, user).catch((err) => { throw new BadRequestException(err.message); @@ -381,7 +383,7 @@ export class CourseService { return '/courses'; } } else { - return `/courses?err=notInCourse`; + return `/courses?err=${QUERY_PARAMS.queueInvite.error.notInCourse}`; } } @@ -906,4 +908,105 @@ export class CourseService { return createdQueue; } + + async createProfInvite( + courseId: number, + maxUses: number, + adminUserId: number, + expiresAt?: Date, + ): Promise { + return await ProfInviteModel.create({ + courseId, + maxUses, + adminUserId, + // if no expiresAt is provided, set it to 7 days from now + expiresAt: expiresAt ?? new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), + }).save(); + } + + async acceptProfInvite( + profInviteId: number, + userId: number, + ): Promise { + // must return a string since we're using this in a redirect URL + // extra logic todo: + // - send email to admin + const profInvite = await ProfInviteModel.findOne({ + where: { id: profInviteId }, + }); + if (!profInvite) { + return `/courses?err=${QUERY_PARAMS.profInvite.error.notFound}&${QUERY_PARAMS.profInvite.error.profInviteId}=${profInviteId}`; + } + + // check if already in course (if so, just redirect and don't consume invite) + // Note that this check happens before the invite expiry check (Meaning that as long as you are in the course, you can always use this link to navigate to the course page. Since I imagine some profs will keep using the same link to get to their course) + const user = await UserModel.findOne({ + where: { id: userId }, + relations: { + courses: true, + organizationUser: true, + }, + }); + if (!user) { + return `/courses?err=${QUERY_PARAMS.profInvite.error.userNotFound}`; + } + const existingUserCourse = user.courses.find( + (uc) => uc.courseId === profInvite.courseId, + ); + if (existingUserCourse) { + if (existingUserCourse.role === Role.PROFESSOR) { + return `/course/${profInvite.courseId}`; // no notice if you're just a prof already in the course + } else { + // Weird case: The user is already in the course as a student or TA. + // In this case, the admin messed up and should promote them manually. + + // TODO: send email + return `/course/${profInvite.courseId}`; + } + } + + if (profInvite.expiresAt < new Date()) { + return `/courses?err=${QUERY_PARAMS.profInvite.error.expired}&${QUERY_PARAMS.profInvite.error.expiresAt}=${profInvite.expiresAt.toLocaleDateString()}`; + } + if (profInvite.maxUses <= profInvite.usesUsed) { + return `/courses?err=${QUERY_PARAMS.profInvite.error.maxUsesReached}&${QUERY_PARAMS.profInvite.error.maxUses}=${profInvite.maxUses}`; + } + // Do this check AFTER other checks since it's assumed that admins only click on a prof invite link to check if it's working + if (user.organizationUser.role === OrganizationRole.ADMIN) { + if (existingUserCourse) { + return `/course/${profInvite.courseId}?notice=${QUERY_PARAMS.profInvite.notice.adminAlreadyInCourse}`; + } else { + await UserCourseModel.create({ + userId, + courseId: profInvite.courseId, + role: Role.PROFESSOR, + }).save(); + + // TODO: send email + return `/course/${profInvite.courseId}?notice=${QUERY_PARAMS.profInvite.notice.adminAcceptedInviteNotConsumed}`; + } + } + + await UserCourseModel.create({ + userId, + courseId: profInvite.courseId, + role: Role.PROFESSOR, + }).save(); + if ( + profInvite.makeOrgProf && + user.organizationUser.role === OrganizationRole.MEMBER + ) { + await OrganizationUserModel.update( + { + userId: userId, + organizationId: user.organizationUser.organizationId, + }, + { role: OrganizationRole.PROFESSOR }, + ); + // TODO: send email + } else { + // TODO: send email + } + return `/course/${profInvite.courseId}?notice=${QUERY_PARAMS.profInvite.notice.inviteAccepted}`; + } } diff --git a/packages/server/src/course/prof-invite.entity.ts b/packages/server/src/course/prof-invite.entity.ts new file mode 100644 index 000000000..f42180941 --- /dev/null +++ b/packages/server/src/course/prof-invite.entity.ts @@ -0,0 +1,59 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToOne, + PrimaryColumn, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { CourseModel } from './course.entity'; +import { UserModel } from 'profile/user.entity'; +import { Exclude } from 'class-transformer'; + +/** + * These are temporary invite links that will automatically promote the user to professor when accepted + */ +@Entity('prof_invite_model') +export class ProfInviteModel extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column() + courseId: number; + + @ManyToOne((type) => CourseModel, (course) => course.profInvites, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'courseId' }) + @Exclude() + course: CourseModel; + + @Column({ default: 1 }) + maxUses: number; + + // I thought about keeping track with a relationship with the users table but meh I don't think it'll add much benefit + @Column({ default: 0 }) + usesUsed: number; + + @CreateDateColumn() + createdAt: Date; + + @Column() + expiresAt: Date; + + @Column('boolean', { default: true }) + makeOrgProf: boolean; + + @Column() + adminUserId: number; + + // To keep track of what admin created the invite (useful for sending them an email when the invite is used) + @ManyToOne((type) => UserModel, (user) => user.createdProfInvites, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'adminUserId' }) + adminUser: UserModel; +} diff --git a/packages/server/src/profile/user.entity.ts b/packages/server/src/profile/user.entity.ts index 192f34388..468fb3eed 100644 --- a/packages/server/src/profile/user.entity.ts +++ b/packages/server/src/profile/user.entity.ts @@ -28,6 +28,7 @@ import { UnreadAsyncQuestionModel } from '../asyncQuestion/unread-async-question import { AsyncQuestionCommentModel } from '../asyncQuestion/asyncQuestionComment.entity'; import { AsyncQuestionModel } from '../asyncQuestion/asyncQuestion.entity'; import { QuestionModel } from '../question/question.entity'; +import { ProfInviteModel } from 'course/prof-invite.entity'; @Entity('user_model') export class UserModel extends BaseEntity { @@ -165,4 +166,8 @@ export class UserModel extends BaseEntity { @OneToMany((type) => AsyncQuestionCommentModel, (aqc) => aqc.creator) @Exclude() asyncQuestionComments: AsyncQuestionCommentModel[]; + + @OneToMany((type) => ProfInviteModel, (profInvite) => profInvite.adminUser) + @Exclude() + createdProfInvites: ProfInviteModel[]; } From 8953f84a562222d04226808875d82265f1dc045d Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Thu, 6 Nov 2025 15:03:44 -0800 Subject: [PATCH 04/27] --amend --- packages/server/ormconfig.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/server/ormconfig.ts b/packages/server/ormconfig.ts index 3e5d8daa7..9ffa23bb3 100644 --- a/packages/server/ormconfig.ts +++ b/packages/server/ormconfig.ts @@ -52,6 +52,7 @@ import { ChatbotProviderModel } from './src/chatbot/chatbot-infrastructure-model import { CourseChatbotSettingsModel } from './src/chatbot/chatbot-infrastructure-models/course-chatbot-settings.entity'; import { OrganizationChatbotSettingsModel } from './src/chatbot/chatbot-infrastructure-models/organization-chatbot-settings.entity'; import { LLMTypeModel } from './src/chatbot/chatbot-infrastructure-models/llm-type.entity'; +import { ProfInviteModel } from './src/course/prof-invite.entity'; // set .envs to their default values if the developer hasn't yet set them if (fs.existsSync('.env')) { config(); @@ -131,6 +132,7 @@ const typeorm: DataSourceOptions = { CourseChatbotSettingsModel, OrganizationChatbotSettingsModel, LLMTypeModel, + ProfInviteModel, ], logging: process.env.NODE_ENV !== 'production' From 304ededcff95ced65fd5caa26a1b5ac1d92d0d86 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Thu, 6 Nov 2025 17:14:58 -0800 Subject: [PATCH 05/27] done most of the work for accepting prof invites --- packages/common/index.ts | 14 ++++ packages/frontend/app/(auth)/login/layout.tsx | 16 +++- .../frontend/app/(dashboard)/courses/page.tsx | 9 ++- packages/frontend/app/api/cookieApi.ts | 24 ++++++ packages/frontend/app/api/index.ts | 16 ++++ .../frontend/app/invite/prof/[piid]/page.tsx | 73 +++++++++++++++++++ packages/server/src/auth/auth.controller.ts | 22 +++++- .../server/src/course/course.controller.ts | 38 ++++++++++ packages/server/src/course/course.service.ts | 40 ++++++++-- .../server/src/course/prof-invite.entity.ts | 3 + packages/server/src/login/login.controller.ts | 10 ++- 11 files changed, 250 insertions(+), 15 deletions(-) create mode 100644 packages/frontend/app/invite/prof/[piid]/page.tsx diff --git a/packages/common/index.ts b/packages/common/index.ts index 20a167cf5..7391b09f4 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -1031,6 +1031,16 @@ export class QueuePartial { courseId!: number } +export class AcceptProfInviteParams { + @IsString() + code!: string +} + +export type GetProfInviteDetailsResponse = { + courseId: number + orgId: number +} + /** * Used when editing QueueInvites */ @@ -2763,6 +2773,7 @@ export enum OrgRoleChangeReason { manualModification = 'manualModification', joinedOrganizationMember = 'joinedOrganizationMember', joinedOrganizationProfessor = 'joinedOrganizationProfessor', + acceptedProfInvite = 'acceptedProfInvite', unknown = 'unknown', } @@ -2770,6 +2781,7 @@ export enum OrgRoleChangeReasonMap { manualModification = 'Role was manually modified by an organization member with sufficient permissions.', joinedOrganizationMember = 'User joined the organization and gained the member role.', joinedOrganizationProfessor = 'User joined the organization and gained the professor role.', + acceptedProfInvite = 'User accepted a professor invite with makeOrgProf flag set to true given by the given admin user.', unknown = '', } @@ -3882,7 +3894,9 @@ export const QUERY_PARAMS = { notFound: 'prof_invite_not_found', profInviteId: 'pinvite_id', userNotFound: 'prof_invite_user_not_found', + badCode: 'prof_invite_bad_code', // It's tempting to want to re-organize this better, but it can make the urls more gross to read (e.g. /courses?error=${QUERY_PARAMS.profInviteError.notFound.queryParam}&${QUERY_PARAMS.profInviteError.notFound.extraParams.profInviteId}=${profInviteId}) + // I also considered putting the full error messages here, but they're only used in one place and I think would do more harm than good for maintainability }, notice: { adminAlreadyInCourse: 'pi_admin_already_in_course', diff --git a/packages/frontend/app/(auth)/login/layout.tsx b/packages/frontend/app/(auth)/login/layout.tsx index 094c76887..fa5b7bf5b 100644 --- a/packages/frontend/app/(auth)/login/layout.tsx +++ b/packages/frontend/app/(auth)/login/layout.tsx @@ -13,7 +13,21 @@ export default async function Layout({ const cookieStore = await cookies() const queueInviteCookieString = cookieStore.get('queueInviteInfo') - if (queueInviteCookieString) { + const profInviteCookieString = cookieStore.get('profInviteInfo') + if (profInviteCookieString) { + const decodedCookie = decodeURIComponent(profInviteCookieString.value) + const cookieParts = decodedCookie.split(',') + const profInviteId = cookieParts[0] + const orgId = cookieParts[1] + const courseId = cookieParts[2] + //const profInviteCode = cookieParts[3] + if (Number(profInviteId)) { + invitedOrgId = Number(orgId) + } + if (Number(courseId)) { + invitedCourseId = Number(courseId) + } + } else if (queueInviteCookieString) { const decodedCookie = decodeURIComponent(queueInviteCookieString.value) const cookieParts = decodedCookie.split(',') const courseId = cookieParts[0] diff --git a/packages/frontend/app/(dashboard)/courses/page.tsx b/packages/frontend/app/(dashboard)/courses/page.tsx index 5efbd6015..cea7a42fe 100644 --- a/packages/frontend/app/(dashboard)/courses/page.tsx +++ b/packages/frontend/app/(dashboard)/courses/page.tsx @@ -80,17 +80,20 @@ export default function CoursesPage(): ReactElement { let profInviteMsg = '' switch (error) { case QUERY_PARAMS.profInvite.error.expired: - profInviteMsg = `That prof invite has expired at ${searchParams.get(QUERY_PARAMS.profInvite.error.expiresAt)}` + profInviteMsg = `That professor invite has expired at ${searchParams.get(QUERY_PARAMS.profInvite.error.expiresAt)}` break case QUERY_PARAMS.profInvite.error.maxUsesReached: - profInviteMsg = `That prof invite has reached the maximum number of uses (${searchParams.get(QUERY_PARAMS.profInvite.error.maxUses)})` + profInviteMsg = `That professor invite has reached the maximum number of uses (${searchParams.get(QUERY_PARAMS.profInvite.error.maxUses)})` break case QUERY_PARAMS.profInvite.error.notFound: - profInviteMsg = `That prof invite of ID ${searchParams.get(QUERY_PARAMS.profInvite.error.profInviteId)} has been deleted or does not exist. If you believe this is an error, please contact an admin.` + profInviteMsg = `That professor invite of ID ${searchParams.get(QUERY_PARAMS.profInvite.error.profInviteId)} has been deleted or does not exist. If you believe this is an error, please contact an admin.` break case QUERY_PARAMS.profInvite.error.userNotFound: profInviteMsg = `User does not exist. Please contact an admin.` break + case QUERY_PARAMS.profInvite.error.badCode: + profInviteMsg = `The professor invite code in the link is incorrect. Please ensure the invite link is fully intact. Otherwise, please contact an admin.` + break default: profInviteMsg = `An unexpected error occurred: ${error}` } diff --git a/packages/frontend/app/api/cookieApi.ts b/packages/frontend/app/api/cookieApi.ts index 00eeb08b0..6af62341d 100644 --- a/packages/frontend/app/api/cookieApi.ts +++ b/packages/frontend/app/api/cookieApi.ts @@ -54,3 +54,27 @@ export async function setQueueInviteCookie( console.error('Failed to set queue invite cookie: ' + error) } } + +export async function setProfInviteCookie( + profInviteId: number, + orgId: number, + courseId: number, + profInviteCode: string, +): Promise { + try { + const cookieStore = await cookies() + cookieStore.set( + 'profInviteInfo', + `${profInviteId},${orgId},${courseId},${profInviteCode}`, + { + httpOnly: true, + secure: true, + maxAge: 3600, // 1 hour + path: '/', + sameSite: 'none', + }, + ) + } catch (error) { + console.error('Failed to set prof invite cookie: ' + error) + } +} diff --git a/packages/frontend/app/api/index.ts b/packages/frontend/app/api/index.ts index 91cdb3eaa..c3d368dd3 100644 --- a/packages/frontend/app/api/index.ts +++ b/packages/frontend/app/api/index.ts @@ -119,6 +119,8 @@ import { UserMailSubscription, LMSSyncDocumentsResult, CreateCourseResponse, + AcceptProfInviteParams, + GetProfInviteDetailsResponse, } from '@koh/common' import Axios, { AxiosInstance, Method } from 'axios' import { plainToClass } from 'class-transformer' @@ -665,6 +667,20 @@ class APIClient { toggleFavourited: async (courseId: number) => { return this.req('PATCH', `/api/v1/courses/${courseId}/toggle_favourited`) }, + acceptProfInvite: async ( + piid: number, + body: AcceptProfInviteParams, + ): Promise => // performs redirect + this.req( + 'GET', + `/api/v1/courses/accept_prof_invite/${piid}`, + undefined, + body, + ), + getProfInviteDetails: async ( + piid: number, + ): Promise => + this.req('GET', `/api/v1/courses/org_id_for_prof_invite/${piid}`), } emailNotification = { get: async (): Promise => diff --git a/packages/frontend/app/invite/prof/[piid]/page.tsx b/packages/frontend/app/invite/prof/[piid]/page.tsx new file mode 100644 index 000000000..85492febf --- /dev/null +++ b/packages/frontend/app/invite/prof/[piid]/page.tsx @@ -0,0 +1,73 @@ +'use client' + +import { Button, Result } from 'antd' +import { ReactElement, useEffect, useState } from 'react' +import { useSearchParams } from 'next/navigation' +import CenteredSpinner from '@/app/components/CenteredSpinner' +import Link from 'next/link' +import { setProfInviteCookie } from '@/app/api/cookieApi' +import { API } from '@/app/api' +import { useRouter } from 'next/router' +import { getErrorMessage } from '@/app/utils/generalUtils' + +type ProfInvitePageProps = { + params: { piid: string } // piid stands for Prof Invite ID +} + +export default function ProfInvitePage( + props: ProfInvitePageProps, +): ReactElement { + const searchParams = useSearchParams() + const profInviteCode = searchParams.get('code') || '' + const [errorMessage, setErrorMessage] = useState(null) + const router = useRouter() + + useEffect(() => { + // accept the invite right away if logged in. + API.profile + .index() + .then(async (userInfo) => { + await API.course.acceptProfInvite(Number(props.params.piid), { + code: profInviteCode, + }) + }) + .catch(async () => { + // If not logged in, set cookies and redirect to /login + await API.course + .getProfInviteDetails(Number(props.params.piid)) + .then(async (details) => { + await setProfInviteCookie( + Number(props.params.piid), + details.orgId, + details.courseId, + profInviteCode, + ) + .then(() => { + router.push(`/login`) + }) + .catch((err) => { + setErrorMessage(getErrorMessage(err)) + }) + }) + .catch((err) => { + setErrorMessage(getErrorMessage(err)) + }) + }) + }, []) + + if (errorMessage) { + return ( + + + + } + /> + ) + } else { + return + } +} diff --git a/packages/server/src/auth/auth.controller.ts b/packages/server/src/auth/auth.controller.ts index 820d6934b..673797f71 100644 --- a/packages/server/src/auth/auth.controller.ts +++ b/packages/server/src/auth/auth.controller.ts @@ -168,8 +168,18 @@ export class AuthController { await emailToken.save(); const cookie = getCookie(req, '__SECURE_REDIRECT'); const queueInviteCookie = getCookie(req, 'queueInviteInfo'); + const profInviteCookie = getCookie(req, 'profInviteInfo'); - if (queueInviteCookie) { + if (profInviteCookie) { + await this.courseService + .acceptProfInvite(profInviteCookie, userId) + .then((url) => { + res.clearCookie('profInviteInfo'); + return res.status(HttpStatus.TEMPORARY_REDIRECT).send({ + redirectUri: url, + }); + }); + } else if (queueInviteCookie) { await this.courseService .getQueueInviteRedirectURLandInviteToCourse(queueInviteCookie, userId) .then((url) => { @@ -511,8 +521,16 @@ export class AuthController { let redirectUrl: string; const cookie = getCookie(req, '__SECURE_REDIRECT'); const queueInviteCookie = getCookie(req, 'queueInviteInfo'); + const profInviteCookie = getCookie(req, 'profInviteInfo'); - if (queueInviteCookie) { + if (profInviteCookie) { + await this.courseService + .acceptProfInvite(profInviteCookie, userId) + .then((url) => { + redirectUrl = url; + res.clearCookie('profInviteInfo'); + }); + } else if (queueInviteCookie) { await this.courseService .getQueueInviteRedirectURLandInviteToCourse(queueInviteCookie, userId) .then((url) => { diff --git a/packages/server/src/course/course.controller.ts b/packages/server/src/course/course.controller.ts index d6e732b0c..9e6289575 100644 --- a/packages/server/src/course/course.controller.ts +++ b/packages/server/src/course/course.controller.ts @@ -1,4 +1,5 @@ import { + AcceptProfInviteParams, CourseCloneAttributes, CourseSettingsRequestBody, CourseSettingsResponse, @@ -69,6 +70,7 @@ import { OrgOrCourseRolesGuard } from 'guards/org-or-course-roles.guard'; import { CourseRoles } from 'decorators/course-roles.decorator'; import { OrgRoles } from 'decorators/org-roles.decorator'; import { OrganizationService } from '../organization/organization.service'; +import { ProfInviteModel } from './prof-invite.entity'; @Controller('courses') @UseInterceptors(ClassSerializerInterceptor) @@ -1092,4 +1094,40 @@ export class CourseController { return newUserCourse; } + + // Allow logged-in users to accept a prof invite + @Get('accept_prof_invite/:piid') + @UseGuards(JwtAuthGuard, EmailVerifiedGuard) + async acceptProfInvite( + @Param('piid', ParseIntPipe) piid: number, + @Body() body: AcceptProfInviteParams, + @UserId() userId: number, + @Res() res: Response, + ): Promise { + const url = await this.courseService.acceptProfInvite(body.code, userId); + res.status(HttpStatus.FOUND).redirect(url); + return; + } + + // just returns the course id and org id for given prof invite + @Get('prof_invite/:piid') + async getProfInviteDetails( + @Param('piid', ParseIntPipe) piid: number, + ): Promise<{ courseId: number; orgId: number }> { + const profInvite = await ProfInviteModel.findOne({ + where: { id: piid }, + relations: { + course: { + organizationCourse: true, + }, + }, + }); + if (!profInvite) { + throw new NotFoundException('Prof invite not found'); + } + return { + courseId: profInvite.courseId, + orgId: profInvite.course.organizationCourse.organizationId, + }; + } } diff --git a/packages/server/src/course/course.service.ts b/packages/server/src/course/course.service.ts index 01120c75d..36cfbf16b 100644 --- a/packages/server/src/course/course.service.ts +++ b/packages/server/src/course/course.service.ts @@ -7,6 +7,7 @@ import { GetCourseUserInfoResponse, MailServiceType, OrganizationRole, + OrgRoleChangeReason, QUERY_PARAMS, QueueConfig, QueueTypes, @@ -45,6 +46,8 @@ import { QueueModel } from 'queue/queue.entity'; import { SuperCourseModel } from './super-course.entity'; import { ChatbotDocPdfModel } from 'chatbot/chatbot-doc-pdf.entity'; import { ProfInviteModel } from './prof-invite.entity'; +import { OrganizationService } from 'organization/organization.service'; +import { randomBytes } from 'node:crypto'; @Injectable() export class CourseService { @@ -52,6 +55,7 @@ export class CourseService { private readonly mailService: MailService, private readonly chatbotApiService: ChatbotApiService, private readonly dataSource: DataSource, + private readonly organizationService: OrganizationService, ) {} async getTACheckInCheckOutTimes( @@ -334,7 +338,7 @@ export class CourseService { // check if the queueInvite exists and if it will invite to course const queueInvite = await QueueInviteModel.findOne({ where: { - queueId: parseInt(queueId), + queueId: Number(queueId), }, }); // get the user to see if they are in the course @@ -364,7 +368,7 @@ export class CourseService { // get course const course = await CourseModel.findOne({ where: { - id: parseInt(courseId), + id: Number(courseId), }, }); if (!course) { @@ -919,20 +923,26 @@ export class CourseService { courseId, maxUses, adminUserId, + code: randomBytes(6).toString('hex'), // 12 character long string. Could go longer but the invite url will look more gross // if no expiresAt is provided, set it to 7 days from now expiresAt: expiresAt ?? new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), }).save(); } async acceptProfInvite( - profInviteId: number, + profInviteCookie: string, userId: number, ): Promise { // must return a string since we're using this in a redirect URL - // extra logic todo: - // - send email to admin + const decodedCookie = decodeURIComponent(profInviteCookie); + const splitCookie = decodedCookie.split(','); + const profInviteId = splitCookie[0]; + // const orgId = splitCookie[1]; + // const courseId = splitCookie[2]; + const profInviteCode = splitCookie[3]; + const profInvite = await ProfInviteModel.findOne({ - where: { id: profInviteId }, + where: { id: Number(profInviteId) }, }); if (!profInvite) { return `/courses?err=${QUERY_PARAMS.profInvite.error.notFound}&${QUERY_PARAMS.profInvite.error.profInviteId}=${profInviteId}`; @@ -960,7 +970,7 @@ export class CourseService { // Weird case: The user is already in the course as a student or TA. // In this case, the admin messed up and should promote them manually. - // TODO: send email + // TODO: send email that says this return `/course/${profInvite.courseId}`; } } @@ -968,9 +978,12 @@ export class CourseService { if (profInvite.expiresAt < new Date()) { return `/courses?err=${QUERY_PARAMS.profInvite.error.expired}&${QUERY_PARAMS.profInvite.error.expiresAt}=${profInvite.expiresAt.toLocaleDateString()}`; } - if (profInvite.maxUses <= profInvite.usesUsed) { + if (profInvite.usesUsed >= profInvite.maxUses) { return `/courses?err=${QUERY_PARAMS.profInvite.error.maxUsesReached}&${QUERY_PARAMS.profInvite.error.maxUses}=${profInvite.maxUses}`; } + if (profInvite.code !== profInviteCode) { + return `/courses?err=${QUERY_PARAMS.profInvite.error.badCode}`; + } // Do this check AFTER other checks since it's assumed that admins only click on a prof invite link to check if it's working if (user.organizationUser.role === OrganizationRole.ADMIN) { if (existingUserCourse) { @@ -987,11 +1000,14 @@ export class CourseService { } } + // add user to the course as a professor await UserCourseModel.create({ userId, courseId: profInvite.courseId, role: Role.PROFESSOR, }).save(); + profInvite.usesUsed++; + await profInvite.save(); if ( profInvite.makeOrgProf && user.organizationUser.role === OrganizationRole.MEMBER @@ -1003,6 +1019,14 @@ export class CourseService { }, { role: OrganizationRole.PROFESSOR }, ); + await this.organizationService.addRoleHistory( + user.organizationUser.organizationId, + OrganizationRole.MEMBER, + OrganizationRole.PROFESSOR, + profInvite.adminUserId, + userId, + OrgRoleChangeReason.acceptedProfInvite, + ); // TODO: send email } else { // TODO: send email diff --git a/packages/server/src/course/prof-invite.entity.ts b/packages/server/src/course/prof-invite.entity.ts index f42180941..aecfe541e 100644 --- a/packages/server/src/course/prof-invite.entity.ts +++ b/packages/server/src/course/prof-invite.entity.ts @@ -44,6 +44,9 @@ export class ProfInviteModel extends BaseEntity { @Column() expiresAt: Date; + @Column('text') + code: string; + @Column('boolean', { default: true }) makeOrgProf: boolean; diff --git a/packages/server/src/login/login.controller.ts b/packages/server/src/login/login.controller.ts index 5fb02097e..fc64f66bc 100644 --- a/packages/server/src/login/login.controller.ts +++ b/packages/server/src/login/login.controller.ts @@ -161,8 +161,16 @@ export class LoginController { let redirectUrl: string; const cookie = getCookie(req, '__SECURE_REDIRECT'); const queueInviteCookie = getCookie(req, 'queueInviteInfo'); + const profInviteCookie = getCookie(req, 'profInviteInfo'); - if (queueInviteCookie) { + if (profInviteCookie) { + await this.courseService + .acceptProfInvite(profInviteCookie, userId) + .then((url) => { + redirectUrl = url; + res.clearCookie('profInviteInfo'); + }); + } else if (queueInviteCookie) { await this.courseService .getQueueInviteRedirectURLandInviteToCourse(queueInviteCookie, userId) .then((url) => { From 749514e597d1eaf2dcdc80cc1a0fa767c32b9476 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Thu, 6 Nov 2025 18:27:48 -0800 Subject: [PATCH 06/27] most of the work done for CRUDing prof invites --- packages/common/index.ts | 37 +++++ .../app/(dashboard)/components/EditCourse.tsx | 21 +++ .../(dashboard)/components/ProfInvites.tsx | 150 ++++++++++++++++++ packages/frontend/app/api/index.ts | 27 +++- .../frontend/app/invite/prof/[piid]/page.tsx | 2 +- .../server/src/course/course.controller.ts | 89 ++++++++++- packages/server/src/course/course.service.ts | 8 +- .../server/src/course/prof-invite.entity.ts | 16 +- .../src/organization/organization.entity.ts | 6 + 9 files changed, 350 insertions(+), 6 deletions(-) create mode 100644 packages/frontend/app/(dashboard)/components/ProfInvites.tsx diff --git a/packages/common/index.ts b/packages/common/index.ts index 7391b09f4..6f2ab0c23 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -1031,6 +1031,43 @@ export class QueuePartial { courseId!: number } +export type GetProfInviteResponse = { + course: { + id: number + name: string + } + adminUser: { + id: number + name: string + email: string + } + id: number + code: string + maxUses: number + usesUsed: number + createdAt: Date + expiresAt: Date + makeOrgProf: boolean +} +export class CreateProfInviteParams { + @IsInt() + orgId!: number + @IsInt() + courseId!: number + + @IsOptional() + @IsInt() + maxUses?: number + + @IsOptional() + @IsDate() + expiresAt?: Date + + @IsOptional() + @IsBoolean() + makeOrgProf?: boolean +} + export class AcceptProfInviteParams { @IsString() code!: string diff --git a/packages/frontend/app/(dashboard)/components/EditCourse.tsx b/packages/frontend/app/(dashboard)/components/EditCourse.tsx index 1de0361b7..af5a9c0a1 100644 --- a/packages/frontend/app/(dashboard)/components/EditCourse.tsx +++ b/packages/frontend/app/(dashboard)/components/EditCourse.tsx @@ -4,6 +4,7 @@ import { API } from '@/app/api' import { GetOrganizationResponse, OrganizationCourseResponse, + OrganizationRole, Role, User, } from '@koh/common' @@ -20,6 +21,7 @@ import { QuestionCircleOutlined } from '@ant-design/icons' import CourseCloneFormModal from './CourseCloneFormModal' import { useOrganizationSettings } from '@/app/hooks/useOrganizationSettings' import { checkCourseCreatePermissions } from '@/app/utils/generalUtils' +import ProfInvites from './ProfInvites' type EditCourseProps = { courseId: number @@ -124,6 +126,25 @@ const EditCourse: React.FC = ({ )} + {user.organization?.organizationRole === OrganizationRole.ADMIN && ( + +
Professor Invites (Admin Only)
+
+ + Help + +
+
+ } + > + +
+ )} {checkCourseCreatePermissions(userInfo, organizationSettings) && ( diff --git a/packages/frontend/app/(dashboard)/components/ProfInvites.tsx b/packages/frontend/app/(dashboard)/components/ProfInvites.tsx new file mode 100644 index 000000000..e3341f3d9 --- /dev/null +++ b/packages/frontend/app/(dashboard)/components/ProfInvites.tsx @@ -0,0 +1,150 @@ +'use client' + +import { API } from '@/app/api' +import { useUserInfo } from '@/app/contexts/userContext' +import { getErrorMessage } from '@/app/utils/generalUtils' +import printQRCode from '@/app/utils/QRCodePrintUtils' +import { CopyOutlined, DeleteOutlined, QrcodeOutlined } from '@ant-design/icons' +import { + GetProfInviteResponse, + OrganizationCourseResponse, + OrganizationRole, +} from '@koh/common' +import { Button, Form, Input, List, message } from 'antd' +import { useCallback, useEffect, useState } from 'react' + +interface FormValues { + maxUses: number | null + expiresAt: Date | null + makeOrgProf: boolean | null +} + +type ProfInvitesProps = { + courseData: OrganizationCourseResponse +} + +const ProfInvites: React.FC = ({ courseData }) => { + const [form] = Form.useForm() + const { userInfo } = useUserInfo() + const [profInvites, setProfInvites] = useState([]) + + const fetchProfInvites = () => { + if (userInfo.organization?.organizationRole !== OrganizationRole.ADMIN) { + return // shouldn't be necessary since the component is only rendered if the user is an admin but just in case + } + if (courseData.organizationId && courseData.courseId) { + API.course + .getAllProfInvites(courseData.organizationId, courseData.courseId) + .then((profInvites) => { + setProfInvites(profInvites) + }) + } else { + message.error( + 'Error fetching ProfInvites: organizationId or courseId not set', + ) + } + } + + useEffect(() => { + fetchProfInvites() + }, []) + + return ( +
+
submit(values)} + > +
+ + + + +
+
+ ( + + )} + /> +
+ ) +} + +const ProfInviteItem: React.FC<{ + profInvite: GetProfInviteResponse + fetchProfInvites: () => void +}> = ({ profInvite, fetchProfInvites }) => { + const [copyLinkText, setCopyLinkText] = useState('Copy Link') + const [isDeleteLoading, setIsDeleteLoading] = useState(false) + + const isHttps = window.location.protocol === 'https:' + const baseURL = `${isHttps ? 'https' : 'http'}://${window.location.host}` + const inviteURL = `${baseURL}/invite/${profInvite.id}?&c=${profInvite.code}` + + const handleCopy = () => { + navigator.clipboard.writeText(inviteURL).then(() => { + setCopyLinkText('Copied!') + setTimeout(() => { + setCopyLinkText('Copy Link') + }, 1000) + }) + } + return ( + +
+ {profInvite.code} - {profInvite.expiresAt.toLocaleDateString()} +
+
+
{inviteURL}
+ +
+ - ) : ( - <> - )} - - - - - - + +
+ ) } diff --git a/packages/frontend/app/(dashboard)/components/ProfInvites.tsx b/packages/frontend/app/(dashboard)/components/ProfInvites.tsx index e3341f3d9..f99ec64a1 100644 --- a/packages/frontend/app/(dashboard)/components/ProfInvites.tsx +++ b/packages/frontend/app/(dashboard)/components/ProfInvites.tsx @@ -2,21 +2,49 @@ import { API } from '@/app/api' import { useUserInfo } from '@/app/contexts/userContext' -import { getErrorMessage } from '@/app/utils/generalUtils' -import printQRCode from '@/app/utils/QRCodePrintUtils' -import { CopyOutlined, DeleteOutlined, QrcodeOutlined } from '@ant-design/icons' +import { cn, getErrorMessage } from '@/app/utils/generalUtils' +import { + CopyOutlined, + DeleteOutlined, + PlusOutlined, + QuestionCircleOutlined, +} from '@ant-design/icons' import { GetProfInviteResponse, OrganizationCourseResponse, OrganizationRole, } from '@koh/common' -import { Button, Form, Input, List, message } from 'antd' -import { useCallback, useEffect, useState } from 'react' +import { + Button, + Card, + Checkbox, + Col, + DatePicker, + Form, + InputNumber, + List, + message, + Row, + Tooltip, +} from 'antd' +import { useEffect, useState } from 'react' +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' + +dayjs.extend(relativeTime) + +const isInviteUsable = (profInvite: GetProfInviteResponse) => { + console.log(profInvite) + return ( + profInvite.expiresAt > new Date() && + profInvite.usesUsed < profInvite.maxUses + ) +} interface FormValues { - maxUses: number | null - expiresAt: Date | null - makeOrgProf: boolean | null + maxUses: number + expiresAt: dayjs.Dayjs + makeOrgProf: boolean } type ProfInvitesProps = { @@ -27,14 +55,16 @@ const ProfInvites: React.FC = ({ courseData }) => { const [form] = Form.useForm() const { userInfo } = useUserInfo() const [profInvites, setProfInvites] = useState([]) + const [showUnusableProfInvites, setShowUnusableProfInvites] = useState(false) + const [createProfInviteLoading, setCreateProfInviteLoading] = useState(false) - const fetchProfInvites = () => { + const fetchProfInvites = async () => { if (userInfo.organization?.organizationRole !== OrganizationRole.ADMIN) { return // shouldn't be necessary since the component is only rendered if the user is an admin but just in case } if (courseData.organizationId && courseData.courseId) { - API.course - .getAllProfInvites(courseData.organizationId, courseData.courseId) + await API.profInvites + .getAll(courseData.organizationId, courseData.courseId) .then((profInvites) => { setProfInvites(profInvites) }) @@ -45,57 +75,146 @@ const ProfInvites: React.FC = ({ courseData }) => { } } + const usableProfInvites = profInvites.filter((profInvite) => + isInviteUsable(profInvite), + ) + const unusableProfInvites = profInvites.filter( + (profInvite) => !isInviteUsable(profInvite), + ) + useEffect(() => { fetchProfInvites() }, []) + const handleCreateProfInvite = async (values: FormValues) => { + setCreateProfInviteLoading(true) + await API.profInvites + .create(courseData.organizationId, { + orgId: courseData.organizationId, + courseId: courseData.courseId, + maxUses: values.maxUses, + expiresAt: values.expiresAt.toDate(), + makeOrgProf: values.makeOrgProf, + }) + .then(async () => { + await fetchProfInvites() + }) + .catch((error) => { + message.error('Error creating ProfInvite: ' + getErrorMessage(error)) + }) + setCreateProfInviteLoading(false) + } + return ( -
+ +
Professor Invites (Admin Only)
+
+ + Help + +
+
+ } + >
submit(values)} + onFinish={handleCreateProfInvite} > -
- - +
+ + + + + + + + + +
- ( - 0 && ( + ( + + )} + /> + )} + {unusableProfInvites.length > 0 && + (showUnusableProfInvites ? ( + ( + + )} /> - )} - /> -
+ ) : ( +
+ {unusableProfInvites.length} expired/used invites ( + + ) +
+ ))} + ) } const ProfInviteItem: React.FC<{ profInvite: GetProfInviteResponse + orgId: number fetchProfInvites: () => void -}> = ({ profInvite, fetchProfInvites }) => { +}> = ({ profInvite, orgId, fetchProfInvites }) => { const [copyLinkText, setCopyLinkText] = useState('Copy Link') const [isDeleteLoading, setIsDeleteLoading] = useState(false) const isHttps = window.location.protocol === 'https:' const baseURL = `${isHttps ? 'https' : 'http'}://${window.location.host}` - const inviteURL = `${baseURL}/invite/${profInvite.id}?&c=${profInvite.code}` + const inviteURL = `${baseURL}/invite/prof/${profInvite.id}?&c=${profInvite.code}` const handleCopy = () => { navigator.clipboard.writeText(inviteURL).then(() => { @@ -105,44 +224,108 @@ const ProfInviteItem: React.FC<{ }, 1000) }) } + + const expiresAt = dayjs(profInvite.expiresAt) + const isInviteUnusable = !isInviteUsable(profInvite) + return ( - -
- {profInvite.code} - {profInvite.expiresAt.toLocaleDateString()} -
-
-
{inviteURL}
- + +
+
+
+ {/* empty div for centering invite link */} +
+
+
{isInviteUnusable ? {inviteURL} : inviteURL}
+ {!isInviteUnusable && ( + + )} +
+
+
+
+
+ = profInvite.maxUses ? 'text-red-700' : '' + } + > + {profInvite.usesUsed} + {' '} + / {profInvite.maxUses} uses used +
+ +
+ Expire{expiresAt.isAfter(dayjs()) ? 's' : 'd'}{' '} + + {expiresAt.isAfter(dayjs()) + ? expiresAt.fromNow() + : expiresAt.toNow()} + +
+
+ +
Admin: {profInvite.adminUser.name}
+
Email: {profInvite.adminUser.email}
+
UserID: {profInvite.adminUser.id}
+
+ Created At:{' '} + {dayjs(profInvite.createdAt).format('YYYY-MM-DD hh:mm A')} +
+
This admin will be notified when the invite is used.
+
+ } + > +
+
+ Created By: {profInvite.adminUser.name}{' '} + {dayjs(profInvite.createdAt).fromNow()} +
+
+ +
+ Make Org Prof:{' '} + {profInvite.makeOrgProf ? ( + Yes + ) : ( + No + )} +
+
- - - - - {usableProfInvites.length > 0 && ( - ( - - )} - /> - )} - {unusableProfInvites.length > 0 && - (showUnusableProfInvites ? ( + + + + + + + + {usableProfInvites.length > 0 && ( ( = ({ courseData }) => { /> )} /> - ) : ( -
- {unusableProfInvites.length} expired/used invites ( - - ) -
- ))} - + )} + {unusableProfInvites.length > 0 && + (showUnusableProfInvites ? ( + ( + + )} + /> + ) : ( +
+ {unusableProfInvites.length} expired/used invites ( + + ) +
+ ))} + + ) } diff --git a/packages/frontend/app/(dashboard)/organization/course/add/page.tsx b/packages/frontend/app/(dashboard)/organization/course/add/page.tsx index ff6875ad9..2a308592e 100644 --- a/packages/frontend/app/(dashboard)/organization/course/add/page.tsx +++ b/packages/frontend/app/(dashboard)/organization/course/add/page.tsx @@ -148,9 +148,19 @@ export default function AddCoursePage(): ReactElement { // need to update userInfo so the course shows up in /courses await userApi.getUser().then((userDetails) => { setUserInfo(userDetails) - router.push( - `/courses?highlightedCourse=${createCourseResponse.courseId}`, - ) + if ( + userDetails.organization?.organizationRole === + OrganizationRole.ADMIN + ) { + // redirect admins to the edit page for courses so they can immediately go create prof invites + router.push( + `/organization/course/${createCourseResponse.courseId}/edit?show-create-prof-notice=true`, + ) + } else { + router.push( + `/courses?highlightedCourse=${createCourseResponse.courseId}`, + ) + } }) }) .catch((error) => { From 0f6aab40609895e6feb993883219455a680918c5 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Wed, 10 Dec 2025 00:28:34 -0800 Subject: [PATCH 15/27] added some UX to the delete button for prof invites --- .../(dashboard)/components/ProfInvites.tsx | 85 +++++++++++++------ 1 file changed, 59 insertions(+), 26 deletions(-) diff --git a/packages/frontend/app/(dashboard)/components/ProfInvites.tsx b/packages/frontend/app/(dashboard)/components/ProfInvites.tsx index 66d07387f..06cb044d4 100644 --- a/packages/frontend/app/(dashboard)/components/ProfInvites.tsx +++ b/packages/frontend/app/(dashboard)/components/ProfInvites.tsx @@ -24,12 +24,13 @@ import { InputNumber, List, message, + Popconfirm, Tooltip, } from 'antd' import { useEffect, useState } from 'react' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' -import { useSearchParams, useRouter, usePathname } from 'next/navigation' +import { useSearchParams } from 'next/navigation' dayjs.extend(relativeTime) @@ -56,8 +57,6 @@ const ProfInvites: React.FC = ({ courseData }) => { const [profInvites, setProfInvites] = useState([]) const [showUnusableProfInvites, setShowUnusableProfInvites] = useState(false) const [createProfInviteLoading, setCreateProfInviteLoading] = useState(false) - const router = useRouter() - const pathname = usePathname() const searchParams = useSearchParams() const showCreateProfNotice = searchParams.get('show-create-prof-notice') === 'true' @@ -239,6 +238,31 @@ const ProfInviteItem: React.FC<{ const [copyLinkText, setCopyLinkText] = useState('Copy Link') const [isDeleteLoading, setIsDeleteLoading] = useState(false) + // used to make deletion of an invite instant only if it's < 1min since creation or < 10s after copying a link. + // Assumption is that the cases where you're usually going to be certain you will want to delete it is either: + // A: right after creating the invite (mistake, etc.) + // B: right before you go to share it (you realize something is wrong, etc.) + const [lastCopiedAt, setLastCopiedAt] = useState(null) + const [isDeletePopoverOpen, setIsDeletePopoverOpen] = useState(false) + const handleDeletePopoverOpenChange = (newOpen: boolean) => { + if (!newOpen) { + // always allow closing + setIsDeletePopoverOpen(newOpen) + return + } + + // Instant delete if: created within 1 min OR copied within last 10s + const now = Date.now() + if ( + now - profInvite.createdAt.getTime() < 60 * 1000 || + (lastCopiedAt && now - lastCopiedAt.getTime() < 10 * 1000) + ) { + handleDelete().then(() => setIsDeletePopoverOpen(false)) + } else { + setIsDeletePopoverOpen(newOpen) + } + } + const isHttps = window.location.protocol === 'https:' const baseURL = `${isHttps ? 'https' : 'http'}://${window.location.host}` const inviteURL = `${baseURL}/invite/prof/${profInvite.id}?&c=${profInvite.code}` @@ -246,12 +270,29 @@ const ProfInviteItem: React.FC<{ const handleCopy = () => { navigator.clipboard.writeText(inviteURL).then(() => { setCopyLinkText('Copied!') + setLastCopiedAt(new Date()) setTimeout(() => { setCopyLinkText('Copy Link') }, 1000) }) } + const handleDelete = async () => { + setIsDeleteLoading(true) + await API.profInvites + .delete(orgId, profInvite.id) + .then(() => { + message.success('Professor invite deleted') + }) + .catch((error) => { + message.error('Error deleting ProfInvite: ' + getErrorMessage(error)) + }) + .finally(() => { + setIsDeleteLoading(false) + fetchProfInvites() + }) + } + const expiresAt = dayjs(profInvite.expiresAt) const isInviteUnusable = !isInviteUsable(profInvite) @@ -268,35 +309,27 @@ const ProfInviteItem: React.FC<{ )} -