Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
44fd50c
deleted redundant state inside EditCourse
AdamFipke Nov 4, 2025
1915210
added highlight in coursesSection to the newly created course
AdamFipke Nov 4, 2025
1b06c26
Backend method for accepting prof invite done mostly. Added new QUERY…
AdamFipke Nov 6, 2025
8953f84
--amend
AdamFipke Nov 6, 2025
304eded
done most of the work for accepting prof invites
AdamFipke Nov 7, 2025
749514e
most of the work done for CRUDing prof invites
AdamFipke Nov 7, 2025
43da957
prof invites: frontend done mostly. reorganised backend. Added enable…
AdamFipke Nov 17, 2025
0706ecd
accepting prof invite now works fully!
AdamFipke Nov 18, 2025
619c13a
fix getRoleHistory not returning the change reason and handled accept…
AdamFipke Nov 18, 2025
156a94d
fix orgId not getting set with prof invites
AdamFipke Nov 18, 2025
a0118b0
made a nice-looking dashboard table of all prof invites for admins
AdamFipke Dec 9, 2025
1d47d37
replaced template strings for query params with new URLSearchParams. …
AdamFipke Dec 9, 2025
330ff12
added emails to admins for when prof invite gets accepted (or attempt…
AdamFipke Dec 10, 2025
28b5811
after creating a course, admins now get redirected to create a prof i…
AdamFipke Dec 10, 2025
0f6aab4
added some UX to the delete button for prof invites
AdamFipke Dec 10, 2025
d2a782d
centralized the 3 professor selectors into 1 component. Added emails …
AdamFipke Dec 10, 2025
b8800dd
prof invite tests. Added email content and subject checking to testUtils
AdamFipke Dec 22, 2025
dfbc43e
merge main
AdamFipke Dec 22, 2025
13312ba
self-review my own code.
AdamFipke Dec 23, 2025
e34b27d
fix expectEmailSent to actually make subject and content optional
AdamFipke Dec 23, 2025
25c35fe
whoops sorry now email tests are fixed
AdamFipke Dec 23, 2025
ff47a44
migration file
AdamFipke Dec 30, 2025
5307da1
merge main (LTI stuff). Tried to fix the things
AdamFipke Jan 20, 2026
29b6948
fix nestjs depedency issues
AdamFipke Jan 20, 2026
94642d2
probably fix login.service.spec tests but they've decided to not be r…
AdamFipke Jan 20, 2026
a7eb2f7
fix login.service.spec tests to use new query_params const
AdamFipke Jan 21, 2026
9416bab
remove one unused import
AdamFipke Jan 21, 2026
a3864a7
handled the off-chance acceptProfInviteFromCookie throws an error and…
AdamFipke Feb 3, 2026
d79df9e
missed a file rename in an import for prof invites, was causing an er…
AdamFipke Feb 3, 2026
87936b1
merge main
AdamFipke Feb 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/DEVELOPING.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,10 @@ Unit test files should be colocated with the file they test.

Integration tests are located in the `test` folder.

`yarn test` at root level runs all tests, but you can also selectively run tests by running `yarn test` while inside a package.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It took me so long to figure out what this meant and now I feel silly. It's literally just saying "if you run yarn test in packages/frontend it will run frontend tests. if you run yarn test in packages/server it will run backend tests."

We don't have frontend tests, so I just removed it to reduce confusion


~~End to end (E2E) testing is in it's own folder and done with Cypress. These should be used to test core user flows. To run them headlessly (without a graphics server), do `yarn cypress run`. To watch them actually run interactively, you can use `yarn cypress open`. Be aware that this is _super slow_ on local machines.~~ (note: cypress has been removed, for now)

To run a specific unit test suite, you can run `yarn test:unit suite-name` e.g. `yarn test:unit course`

If your tests are failing with a message about "deadlock something whatever", do `yarn test --run-in-band`. This makes the tests run sequentially.

If `yarn test` is not running all of the tests, navigate to `server/test` folder and run `yarn jest --config ./test/jest-integration.json -i --run-in-band` if you would like to run all the integration tests. To run the tests of a specific integration test file (e.g. course.integration.ts), you can use `yarn jest --config ./test/jest-integration.json -i --run-in-band course`
Expand Down
103 changes: 101 additions & 2 deletions packages/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ export enum MailServiceType {
ASYNC_QUESTION_NEW_COMMENT_ON_MY_POST = 'async_question_new_comment_on_my_post',
ASYNC_QUESTION_NEW_COMMENT_ON_OTHERS_POST = 'async_question_new_comment_on_others_post',
COURSE_CLONE_SUMMARY = 'course_clone_summary',
ADMIN_NOTICE = 'admin_notice', // currently used for all prof invite admin emails. Just wanted something generic for it.
}
/**
* Represents one of three possible user roles in a course.
Expand Down Expand Up @@ -1078,6 +1079,56 @@ export class QueuePartial {
courseId!: number
}

export class GetProfInviteResponse {
course!: {
id: number
name: string
}
adminUser!: {
id: number
name: string
email: string
}
id!: number
code!: string
maxUses!: number
usesUsed!: number
@Type(() => Date)
createdAt!: Date
@Type(() => Date)
expiresAt!: Date
makeOrgProf!: boolean
}
export class CreateProfInviteParams {
@IsInt()
orgId!: number
@IsInt()
courseId!: number

@IsOptional()
@IsInt()
maxUses?: number

@IsOptional()
@IsDate()
@Type(() => Date)
expiresAt?: Date

@IsOptional()
@IsBoolean()
makeOrgProf?: boolean
}

export class AcceptProfInviteParams {
@IsString()
code!: string
}

export type GetProfInviteDetailsResponse = {
courseId: number
orgId: number
}

/**
* Used when editing QueueInvites
*/
Expand Down Expand Up @@ -2188,11 +2239,17 @@ export type OrganizationProfessor = {
organizationUser: {
id: number
name: string
email: string
}
trueRole?: OrganizationRole
userId: number
}

export type CreateCourseResponse = {
message: string
courseId: number
}

export class UpdateOrganizationCourseDetailsParams {
@IsString()
@IsOptional()
Expand Down Expand Up @@ -2480,10 +2537,10 @@ export class OrganizationCourseResponse {
id?: number

@IsInt()
organizationId?: number
organizationId!: number

@IsInt()
courseId?: number
courseId!: number

course?: GetCourseResponse

Expand Down Expand Up @@ -2977,13 +3034,15 @@ export enum OrgRoleChangeReason {
manualModification = 'manualModification',
joinedOrganizationMember = 'joinedOrganizationMember',
joinedOrganizationProfessor = 'joinedOrganizationProfessor',
acceptedProfInvite = 'acceptedProfInvite',
unknown = 'unknown',
}

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 = '',
}

Expand Down Expand Up @@ -4207,6 +4266,8 @@ export const ERROR_MESSAGES = {
notLoggedIn: 'Must be logged in',
noCourseIdFound: 'No courseId found',
notInCourse: 'Not In This Course',
noOrgId: 'Organization ID not given',
invalidOrgId: 'Invalid Organization ID: Not a valid number',
notAuthorized: "You don't have permissions to perform this action",
userNotInOrganization: 'User not in organization',
mustBeRoleToAccess: (roles: string[]): string =>
Expand Down Expand Up @@ -4340,3 +4401,41 @@ 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',
badCode: 'prof_invite_bad_code',
unknown: 'prof_invite_unknown_error',
// 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',
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
}
46 changes: 30 additions & 16 deletions packages/frontend/app/(auth)/login/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,37 @@ 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] // not used, but left here to showcase they are available
const orgId = Number(cookieParts[1])
const courseId = Number(cookieParts[2])
//const profInviteCode = cookieParts[3]
if (orgId) {
invitedOrgId = orgId
}
if (courseId) {
invitedCourseId = courseId
}
} else if (queueInviteCookieString) {
const decodedCookie = decodeURIComponent(queueInviteCookieString.value)
const cookieParts = decodedCookie.split(',')
const courseId = cookieParts[0]
const queueId = cookieParts[1]
const orgId = cookieParts[2]
const courseId = Number(cookieParts[0])
const queueId = Number(cookieParts[1])
const orgId = Number(cookieParts[2])
const courseInviteCode = cookieParts[3]
? Buffer.from(cookieParts[3], 'base64').toString('utf-8')
: null
if (Number(orgId)) {
invitedOrgId = Number(orgId)
if (orgId) {
invitedOrgId = orgId
}
if (Number(queueId)) {
invitedQueueId = Number(queueId)
if (queueId) {
invitedQueueId = queueId
}
if (Number(courseId)) {
invitedCourseId = Number(courseId)
if (courseId) {
invitedCourseId = courseId
}
if (courseInviteCode) {
// note: this will only get set if the queueInvite had willInviteToCourse set to true
Expand All @@ -42,14 +56,14 @@ export default async function Layout({
const decodedCookie = decodeURIComponent(
redirectCookieString.value,
).split(',')
const orgId = decodedCookie[2]
const courseId = decodedCookie[0]
const orgId = Number(decodedCookie[2])
const courseId = Number(decodedCookie[0])
const courseInviteCode = decodedCookie[1]
if (Number(orgId)) {
invitedOrgId = Number(orgId)
if (orgId) {
invitedOrgId = orgId
}
if (Number(courseId)) {
invitedCourseId = Number(courseId)
if (courseId) {
invitedCourseId = courseId
}
if (courseInviteCode) {
invitedCourseInviteCode = courseInviteCode
Expand Down
72 changes: 4 additions & 68 deletions packages/frontend/app/(dashboard)/components/CourseCloneForm.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,11 @@
'use client'

import { useEffect, useState } from 'react'
import {
Checkbox,
Form,
FormInstance,
Input,
message,
Select,
Tag,
Tooltip,
} from 'antd'
import {
GetOrganizationResponse,
OrganizationProfessor,
OrganizationRole,
} from '@koh/common'
import { Checkbox, Form, FormInstance, Input, message, Select } from 'antd'
import { GetOrganizationResponse, OrganizationProfessor } from '@koh/common'
import { API } from '@/app/api'
import { formatSemesterDate } from '@/app/utils/timeFormatUtils'
import ProfessorSelector from './ProfessorSelector'

type CourseCloneFormProps = {
form: FormInstance
Expand Down Expand Up @@ -71,59 +59,7 @@ const CourseCloneForm: React.FC<CourseCloneFormProps> = ({
className="flex-1"
required
>
<Select
mode="multiple"
placeholder="Select professors"
showSearch
optionFilterProp="label"
options={professors.map((prof: OrganizationProfessor) => ({
key: prof.organizationUser.id,
label: prof.organizationUser.name,
value: prof.organizationUser.id,
}))}
filterSort={(optionA, optionB) =>
(optionA?.label ?? '')
.toLowerCase()
.localeCompare((optionB?.label ?? '').toLowerCase())
}
notFoundContent="There seems to be no professors available. This is likely a server error."
tagRender={(props) => {
const { label, value, closable, onClose } = props
const onPreventMouseDown = (
event: React.MouseEvent<HTMLSpanElement>,
) => {
event.preventDefault()
event.stopPropagation()
}
// find the professor with the given id and see if they have lacksProfOrgRole
const match = professors.find(
(prof) => prof.organizationUser.id === value,
)
const lacksProfOrgRole = ![
OrganizationRole.ADMIN,
OrganizationRole.PROFESSOR,
].includes(match?.trueRole ?? OrganizationRole.MEMBER)
return (
<Tooltip
title={
lacksProfOrgRole
? 'This user lacks the Professor role in this organization, meaning they cannot create their own courses.'
: ''
}
>
<Tag
color={lacksProfOrgRole ? 'orange' : 'blue'}
onMouseDown={onPreventMouseDown}
closable={closable}
onClose={onClose}
style={{ marginInlineEnd: 4 }}
>
{label}
</Tag>
</Tooltip>
)
}}
/>
<ProfessorSelector professors={professors} />
</Form.Item>
)}
<Form.Item
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import { useMemo } from 'react'

interface CoursesSectionTableViewProps {
semesters: SemesterPartial[]
highlightedCourseId?: number
}

const CoursesSectionTableView: React.FC<CoursesSectionTableViewProps> = ({
semesters,
highlightedCourseId,
}) => {
const { userInfo, setUserInfo } = useUserInfo()

Expand Down Expand Up @@ -107,6 +109,9 @@ const CoursesSectionTableView: React.FC<CoursesSectionTableViewProps> = ({
{course.course.sectionGroupName && (
<span className="ml-1 text-sm text-blue-700/50">{`${course.course.sectionGroupName}`}</span>
)}
{highlightedCourseId === course.course.id && (
<span className="ml-1 text-sm text-yellow-500">(New)</span>
)}
</Link>
),
},
Expand Down
Loading