Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions packages/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -989,7 +989,15 @@ export enum QueueTypes {
export enum ExtraTAStatus {
HELPING_IN_ANOTHER_QUEUE = 'Helping student in another queue',
HELPING_IN_ANOTHER_COURSE = 'Helping student in another course',
AWAY = 'Away',
}

export class SetTAExtraStatusParams {
@IsOptional()
@IsEnum(ExtraTAStatus)
status?: ExtraTAStatus | null
}

export interface StaffMember {
id: number
name: string
Expand Down Expand Up @@ -1087,6 +1095,7 @@ export type StaffForStaffList = {
name: string
photoURL?: string
questionHelpedAt?: Date
extraStatus?: ExtraTAStatus
}

// Represents a list of office hours wait times of each hour of the week.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
UpOutlined,
EnvironmentOutlined,
} from '@ant-design/icons'
import { Button, Popconfirm, Row, Switch, Tooltip } from 'antd'
import { Button, Popconfirm, Row, Switch, Tooltip, message } from 'antd'
import moment from 'moment'
import React, { ReactNode, useState } from 'react'
import { useQueue } from '@/app/hooks/useQueue'
Expand All @@ -28,7 +28,11 @@ import RenderEvery from '@/app/components/RenderEvery'
import TagGroupSwitch from './TagGroupSwitch'
import StaffList from './StaffList'
import { getQueueTypeLabel } from '../utils/commonQueueFunctions'
import { QueuePartial, GetQueueChatsResponse } from '@koh/common'
import { QueuePartial, GetQueueChatsResponse, ExtraTAStatus } from '@koh/common'
import { useUserInfo } from '@/app/contexts/userContext'
import { API } from '@/app/api'
import { getErrorMessage } from '@/app/utils/generalUtils'
import { useMediaQuery } from '@/app/hooks/useMediaQuery'

interface QueueInfoColumnProps {
cid: number
Expand Down Expand Up @@ -58,15 +62,26 @@ const QueueInfoColumn: React.FC<QueueInfoColumnProps> = ({
queueChats,
}) => {
const router = useRouter()
const { userInfo } = useUserInfo()
const isMobile = useMediaQuery('(max-width: 768px)')

// const [away, setAway] = useState(false);
// const checkAway = (checked: boolean) => {
// if (!checked) {
// setAway(true);
// } else {
// setAway(false);
// }
// };
const me = queue?.staffList?.find((s) => s.id === userInfo?.id)
const isAway = me?.extraStatus === ExtraTAStatus.AWAY
const [savingAway, setSavingAway] = useState(false)
const awaySwitchId = `away-switch-${queueId}`
const toggleAway = async (checked: boolean) => {
// checked = Away; unchecked = Answering
const newStatus = checked ? ExtraTAStatus.AWAY : null
try {
setSavingAway(true)
await API.taStatus.setExtraStatus(cid, queueId, newStatus)
} catch (err) {
const errorMessage = getErrorMessage(err)
message.error(errorMessage)
} finally {
setSavingAway(false)
}
}
return (
<div className="relative flex flex-shrink-0 flex-col pb-1 md:mt-8 md:w-72 md:pb-7">
{/* only show the queue title and warning here on desktop, it's moved further down on mobile (and placed in queue page.tsx) */}
Expand Down Expand Up @@ -116,8 +131,23 @@ const QueueInfoColumn: React.FC<QueueInfoColumnProps> = ({
{buttons}
</div>

<div className="flex md:mt-3">
<div className="flex items-center justify-between md:mt-3">
<h3 className="mb-0 text-2xl font-semibold">Staff</h3>
{isStaff && (
<div className="ml-auto flex items-center gap-2">
<label htmlFor={awaySwitchId} className="text-sm text-zinc-600">
Away
</label>

<Switch
id={awaySwitchId}
size={isMobile ? 'small' : undefined}
checked={isAway}
onChange={toggleAway}
loading={savingAway}
/>
</div>
)}
{/* Button to hide staff list on mobile */}
<Button
className="md:hidden"
Expand Down Expand Up @@ -147,13 +177,6 @@ const QueueInfoColumn: React.FC<QueueInfoColumnProps> = ({
{isStaff && (
// "Clear Queue" and "Delete Queue" buttons for DESKTOP ONLY - mobile is in EditQueueModal.tsx
<div className="bottom-0 hidden h-full w-full flex-col justify-end text-white md:flex">
{/* <p>Toggle to indicate away </p>
<Switch
onChange={checkAway}
checkedChildren="Answering"
unCheckedChildren="Away"
style={{ width: "200px", marginTop: "-50px", marginBottom: "50px" }}
/> */}
<Popconfirm
title={
'Are you sure you want to clear all students from the queue?'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ interface StatusCardProps {
studentName?: string
helpedAt?: Date
grouped?: boolean
isForPublic?: boolean // true for queue invite page
}
/**
* View component just renders TA status
Expand All @@ -110,6 +111,7 @@ const StatusCard: React.FC<StatusCardProps> = ({
studentName,
helpedAt,
grouped,
isForPublic,
}) => {
const isBusy = !!helpedAt || !!ta.extraStatus
const [canSave, setCanSave] = useState(false)
Expand Down Expand Up @@ -182,8 +184,21 @@ const StatusCard: React.FC<StatusCardProps> = ({
<Row justify="space-between">
<div className="font-bold text-gray-900">{ta.name}</div>
<span>
<Badge status={isBusy ? 'processing' : 'success'} />
{isBusy ? 'Busy' : 'Available'}
<Badge
className="mr-0.5"
status={
ta.extraStatus === ExtraTAStatus.AWAY
? 'error'
: isBusy
? 'processing'
: 'success'
}
/>
{ta.extraStatus === ExtraTAStatus.AWAY
? 'Away'
: isBusy
? 'Busy'
: 'Available'}
</span>
</Row>
<div className="flex items-start justify-between">
Expand All @@ -196,13 +211,15 @@ const StatusCard: React.FC<StatusCardProps> = ({
helpedAt={helpedAt}
extraTAStatus={ta.extraStatus}
/>
) : ta.extraStatus === ExtraTAStatus.AWAY ? (
'Will be back soon'
) : (
// this 1 dot is enough to make the button wrap onto the next row, so i'm only showing it as "..." if there's no button (it looks weird if it's still "..")
'Looking for my next student..' + (isStaff ? '.' : '')
)}
</div>
{/* Students have a button to message their TAs */}
{!isStaff && queueId && (
{!isForPublic && !isStaff && queueId && (
<MessageButton
recipientName={ta.name}
staffId={ta.id}
Expand Down
12 changes: 12 additions & 0 deletions packages/frontend/app/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import {
QueueInviteParams,
QueuePartial,
QueueTypes,
ExtraTAStatus,
RemoveLMSOrganizationParams,
Role,
SemesterPartial,
Expand Down Expand Up @@ -720,6 +721,17 @@ class APIClient {
return this.req('DELETE', `/api/v1/courses/${courseId}/checkout_all`)
}
},
setExtraStatus: async (
courseId: number,
qid: number,
status: ExtraTAStatus | null,
): Promise<void> =>
this.req(
'PATCH',
`/api/v1/courses/${courseId}/ta_status/${qid}`,
undefined,
{ status },
),
}
asyncQuestions = {
get: async (cid: number): Promise<AsyncQuestion[]> =>
Expand Down
1 change: 1 addition & 0 deletions packages/frontend/app/qi/[qid]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ export default function QueueInvitePage(
queueId={queueInviteInfo.queueId}
ta={ta}
helpedAt={ta.questionHelpedAt}
isForPublic
/>
))}
</div>
Expand Down
31 changes: 31 additions & 0 deletions packages/server/migration/20251112000100-AddTAAwayEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddTAAwayEvents20251112000100 implements MigrationInterface {
name = 'AddTAAwayEvents20251112000100';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TYPE "public"."event_model_eventtype_enum" RENAME TO "event_model_eventtype_enum_old"`,
);
await queryRunner.query(
`CREATE TYPE "event_model_eventtype_enum" AS ENUM('taCheckedIn', 'taCheckedOut', 'taCheckedOutForced', 'taCheckedOutEventEnd', 'taMarkedSelfAway', 'taMarkedSelfBack')`,
);
await queryRunner.query(
`ALTER TABLE "event_model" ALTER COLUMN "eventType" TYPE "event_model_eventtype_enum" USING "eventType"::"text"::"event_model_eventtype_enum"`,
);
await queryRunner.query(`DROP TYPE "event_model_eventtype_enum_old"`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "event_model_eventtype_enum_old" AS ENUM('taCheckedIn', 'taCheckedOut', 'taCheckedOutForced', 'taCheckedOutEventEnd')`,
);
await queryRunner.query(
`ALTER TABLE "event_model" ALTER COLUMN "eventType" TYPE "event_model_eventtype_enum_old" USING "eventType"::"text"::"event_model_eventtype_enum_old"`,
);
await queryRunner.query(`DROP TYPE "event_model_eventtype_enum"`);
await queryRunner.query(
`ALTER TYPE "event_model_eventtype_enum_old" RENAME TO "event_model_eventtype_enum"`,
);
}
}
2 changes: 2 additions & 0 deletions packages/server/ormconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { ApplicationConfigModel } from './src/config/application_config.entity';
import { InsightDashboardModel } from './src/insights/dashboard.entity';
import { QueueInviteModel } from './src/queue/queue-invite.entity';
import { QueueChatsModel } from './src/queueChats/queue-chats.entity';
import { QueueStaffModel } from './src/queue/queue-staff.entity';
import { LMSOrganizationIntegrationModel } from './src/lmsIntegration/lmsOrgIntegration.entity';
import { LMSCourseIntegrationModel } from './src/lmsIntegration/lmsCourseIntegration.entity';
import { LMSAssignmentModel } from './src/lmsIntegration/lmsAssignment.entity';
Expand Down Expand Up @@ -113,6 +114,7 @@ const typeorm: DataSourceOptions = {
ApplicationConfigModel,
QueueInviteModel,
QueueChatsModel,
QueueStaffModel,
InsightDashboardModel,
LMSOrganizationIntegrationModel,
LMSCourseIntegrationModel,
Expand Down
46 changes: 46 additions & 0 deletions packages/server/src/course/course.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
UserCourse,
UserTiny,
validateQueueConfigInput,
SetTAExtraStatusParams,
} from '@koh/common';
import {
BadRequestException,
Expand Down Expand Up @@ -592,6 +593,51 @@ export class CourseController {
return { queueId: queue.id };
}

/**
* Allows a TA to set or clear their extra status (e.g., Away) for a specific queue.
*/
@Patch(':id/ta_status/:qid')
@UseGuards(JwtAuthGuard, CourseRolesGuard, EmailVerifiedGuard)
@Roles(Role.PROFESSOR, Role.TA)
async setTAExtraStatus(
@Param('id', ParseIntPipe) courseId: number,
@Param('qid', ParseIntPipe) queueId: number,
@User() user: UserModel,
@Body() body: SetTAExtraStatusParams,
): Promise<void> {
const queue = await QueueModel.findOne({
where: {
id: queueId,
isDisabled: false,
},
relations: {
staffList: true,
},
});

if (!queue) {
throw new NotFoundException(
ERROR_MESSAGES.courseController.queueNotFound,
);
}

// Only allow if the user is checked into this queue
const isInStaffList = queue.staffList.some((s) => s.id === user.id);
if (!isInStaffList) {
throw new BadRequestException('You must be checked in to set status');
}

await this.courseService.setTAExtraStatusForQueue(
queueId,
courseId,
user.id,
body?.status ?? null,
);

await this.queueSSEService.updateQueue(queueId);
return;
}

@Delete(':id/checkout_all')
@UseGuards(JwtAuthGuard, CourseRolesGuard, EmailVerifiedGuard)
@Roles(Role.PROFESSOR, Role.TA)
Expand Down
53 changes: 53 additions & 0 deletions packages/server/src/course/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
TACheckinTimesResponse,
UserCourse,
UserPartial,
ExtraTAStatus,
} from '@koh/common';
import {
BadRequestException,
Expand Down Expand Up @@ -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 { QueueStaffModel } from 'queue/queue-staff.entity';
import * as crypto from 'crypto';

@Injectable()
Expand Down Expand Up @@ -125,6 +127,57 @@ export class CourseService {
return { taCheckinTimes };
}

async setTAExtraStatusForQueue(
queueId: number,
courseId: number,
userId: number,
status: ExtraTAStatus | null,
): Promise<void> {
const allowedStatuses: Array<ExtraTAStatus | null> = [
ExtraTAStatus.AWAY,
null,
];
if (!allowedStatuses.includes(status ?? null)) {
throw new BadRequestException('Invalid status');
}

const joinRow = await QueueStaffModel.findOne({
where: { queueModelId: queueId, userModelId: userId },
});
if (!joinRow) {
// If the row doesn't exist, something is out of sync; bail
throw new BadRequestException('Unable to set status');
}

const prev = joinRow.extraTAStatus;
joinRow.extraTAStatus = status ?? null;
await joinRow.save();

if (
prev !== ExtraTAStatus.AWAY &&
joinRow.extraTAStatus === ExtraTAStatus.AWAY
) {
await EventModel.create({
time: new Date(),
eventType: EventType.TA_MARKED_SELF_AWAY,
userId,
courseId,
queueId,
}).save();
} else if (
prev === ExtraTAStatus.AWAY &&
(joinRow.extraTAStatus === null || joinRow.extraTAStatus === undefined)
) {
await EventModel.create({
time: new Date(),
eventType: EventType.TA_MARKED_SELF_BACK,
userId,
courseId,
queueId,
}).save();
}
}

async removeUserFromCourse(userCourse: UserCourseModel): Promise<void> {
if (!userCourse) {
throw new HttpException(
Expand Down
Loading