diff --git a/apps/anvil/src/email/email.service.ts b/apps/anvil/src/email/email.service.ts index 55ccc13..2cf452a 100644 --- a/apps/anvil/src/email/email.service.ts +++ b/apps/anvil/src/email/email.service.ts @@ -1,13 +1,14 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { InjectQueue } from "@nestjs/bull"; -import type { Location } from "@ignis/types/sign_in"; +import type { Location, QueueEntry } from "@ignis/types/sign_in"; import type { PartialUser } from "@ignis/types/users"; +import { InjectQueue } from "@nestjs/bull"; +import { Injectable, Logger } from "@nestjs/common"; import { Queue } from "bull"; import { render } from "jsx-email"; +import { z } from "zod"; import { SendEmailSchema } from "./dto/send-email.dto"; +import Queued from "./templates/queued"; import { Unqueued } from "./templates/unqueued"; import { WelcomeEmail } from "./templates/welcome"; -import { z } from "zod"; @Injectable() export class EmailService { @@ -49,9 +50,16 @@ export class EmailService { }); } - async sendUnqueuedEmail(recipient: PartialUser, location: Location) { - await this.sendHtml(Unqueued({ ...recipient, location }), { - recipients: [`${recipient.email}@sheffield.ac.uk`], + async sendUnqueuedEmail(place: QueueEntry, location: Location) { + await this.sendHtml(Unqueued({ ...place, location }), { + recipients: [`${place.user.email}@sheffield.ac.uk`], + subject: `Your place in the iForge ${location}`, + }); + } + + async sendQueuedEmail(place: QueueEntry, location: Location) { + await this.sendHtml(Queued({ ...place, location }), { + recipients: [`${place.user.email}@sheffield.ac.uk`], subject: `Your place in the iForge ${location}`, }); } diff --git a/apps/anvil/src/email/templates/queued.tsx b/apps/anvil/src/email/templates/queued.tsx new file mode 100644 index 0000000..a4df273 --- /dev/null +++ b/apps/anvil/src/email/templates/queued.tsx @@ -0,0 +1,36 @@ +import { Location, QueueEntry } from "@ignis/types/sign_in"; +import { Container, Hr, Text } from "jsx-email"; +import * as React from "react"; +import { Email } from "../components/heading"; +import { Link } from "../components/link"; + +export function Queued({ id = "0000-0000-0000-0000", location = "{location}" }: QueueEntry & { location: Location }) { + return ( + +
+ + + Please sign in in the next 15 minutes (by{" "} + {new Date(new Date().getTime() + 1000 * 60 * 15).toLocaleTimeString()} + ). Otherwise, your place will be given to the next person in the queue. + + + + If you no longer require your place please{" "} + + click here + {" "} + to cancel. + + +
+ ); +} + +export default Queued; diff --git a/apps/anvil/src/email/templates/unqueued.tsx b/apps/anvil/src/email/templates/unqueued.tsx index c142def..9caa9d2 100644 --- a/apps/anvil/src/email/templates/unqueued.tsx +++ b/apps/anvil/src/email/templates/unqueued.tsx @@ -1,13 +1,10 @@ -import { Text, Hr, Container } from "jsx-email"; +import { QueueEntry } from "@ignis/types/sign_in"; +import { Container, Hr, Text } from "jsx-email"; import * as React from "react"; import { Email } from "../components/heading"; import { Link } from "../components/link"; -import type { PartialUser } from "@ignis/types/users"; -export function Unqueued({ - id = "0000-0000-0000-0000", - location = "{location}", -}: PartialUser & { location: string }) { +export function Unqueued({ id = "0000-0000-0000-0000", location = "{location}" }: QueueEntry & { location: string }) { return ( Please sign in in the next 15 minutes (by{" "} {new Date(new Date().getTime() + 1000 * 60 * 15).toLocaleTimeString()} - ). Otherwise, your place will be given to the next person in the - queue. + ). Otherwise, your place will be given to the next person in the queue. If you no longer require your place please{" "} click here {" "} diff --git a/apps/anvil/src/sign-in/sign-in.controller.ts b/apps/anvil/src/sign-in/sign-in.controller.ts index f0ef496..aa3ef9c 100644 --- a/apps/anvil/src/sign-in/sign-in.controller.ts +++ b/apps/anvil/src/sign-in/sign-in.controller.ts @@ -1,167 +1,177 @@ -import {CheckAbilities} from "@/auth/authorization/decorators/check-abilities-decorator"; -import {IsAdmin, IsRep} from "@/auth/authorization/decorators/check-roles-decorator"; -import {CaslAbilityGuard} from "@/auth/authorization/guards/casl-ability.guard"; -import {IdempotencyCache} from "@/shared/decorators/idempotency.decorator"; -import {User} from "@/shared/decorators/user.decorator"; -import {ldapLibraryToUcardNumber} from "@/shared/functions/utils"; -import {IdempotencyCacheInterceptor} from "@/shared/interceptors/idempotency-cache.interceptor"; -import {TrainingService} from "@/training/training.service"; -import {UsersService} from "@/users/users.service"; -import {sign_in as sign_in_} from "@ignis/types"; -import type {List, Location, LocationStatus} from "@ignis/types/sign_in"; -import type {User as User_} from "@ignis/types/users"; -import {Body, Controller, Get, Param, ParseIntPipe, Patch, Post, UseGuards, UseInterceptors} from "@nestjs/common"; -import {Logger} from "@nestjs/common"; -import {AuthGuard} from "@nestjs/passport"; -import {FinaliseSignInDto, UpdateSignInDto} from "./dto/sigs-in-dto"; -import {SignInService} from "./sign-in.service"; +import { CheckAbilities } from "@/auth/authorization/decorators/check-abilities-decorator"; +import { IsAdmin, IsRep } from "@/auth/authorization/decorators/check-roles-decorator"; +import { CaslAbilityGuard } from "@/auth/authorization/guards/casl-ability.guard"; +import { IdempotencyCache } from "@/shared/decorators/idempotency.decorator"; +import { User } from "@/shared/decorators/user.decorator"; +import { ldapLibraryToUcardNumber } from "@/shared/functions/utils"; +import { IdempotencyCacheInterceptor } from "@/shared/interceptors/idempotency-cache.interceptor"; +import { TrainingService } from "@/training/training.service"; +import { UsersService } from "@/users/users.service"; +import { sign_in as sign_in_ } from "@ignis/types"; +import type { List, Location, LocationStatus } from "@ignis/types/sign_in"; +import type { User as User_ } from "@ignis/types/users"; +import { + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, + UseGuards, + UseInterceptors +} from "@nestjs/common"; +import { Logger } from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; +import { FinaliseSignInDto, UpdateSignInDto } from "./dto/sigs-in-dto"; +import { SignInService } from "./sign-in.service"; @Controller("location/:location") @UseGuards(AuthGuard("jwt"), CaslAbilityGuard) export class SignInController { - constructor( - private readonly trainingService: TrainingService, - private readonly signInService: SignInService, - private readonly userService: UsersService, - private readonly logger: Logger, - ) { + constructor( + private readonly trainingService: TrainingService, + private readonly signInService: SignInService, + private readonly userService: UsersService, + private readonly logger: Logger, + ) {} + + @Get() + @IsRep() + async getList(@Param("location") location: Location): Promise { + return this.signInService.getList(location); + } + + @Get("sign-in/:ucard_number") + @IsRep() + async signInOptions( + @Param("location") location: Location, + @Param("ucard_number") ucard_number: string, + ): Promise { + this.logger.log( + `Retrieving sign-in options for UCard number: ${ucard_number} at location: ${location}`, + SignInController.name, + ); + const user = await this.signInService.getUserForSignIn(location, ucard_number); + + if (user?.is_rep) { + return { + // reasons, + training: await this.signInService.getTrainings(user.id, location), + ...user, + ...{ infractions: [] }, + }; } - @Get() - @IsRep() - async getList(@Param("location") location: Location): Promise { - return this.signInService.getList(location); + const extras = await this.signInService.preSignInChecks(location, user.ucard_number); + + // const [trainings, reasons] = await Promise.all([ + // this.trainingService.getUserxxTrainingForLocation(user.username, location), + // this.signInService.getSignInReasons(), + // ]); + return { + // reasons, + training: await this.signInService.getTrainings(user.id, location), + ...user, + ...extras, + }; + } + + @Post("sign-in/:ucard_number") + @IsRep() + @UseInterceptors(IdempotencyCacheInterceptor) + @IdempotencyCache(60) + async signIn( + @Param("location") location: Location, + @Param("ucard_number") ucard_number: string, + @Body() finaliseSignInDto: FinaliseSignInDto, + ) { + this.logger.log(`Signing in UCard number: ${ucard_number} at location: ${location}`, SignInController.name); + const ucard_number_ = ldapLibraryToUcardNumber(ucard_number); + if (await this.signInService.isRep(ucard_number_)) { + return await this.signInService.repSignIn(location, ucard_number_, finaliseSignInDto.reason_id); } - @Get("sign-in/:ucard_number") - @IsRep() - async signInOptions( - @Param("location") location: Location, - @Param("ucard_number") ucard_number: string, - ): Promise { - this.logger.log( - `Retrieving sign-in options for UCard number: ${ucard_number} at location: ${location}`, - SignInController.name, - ); - const user = await this.signInService.getUserForSignIn(location, ucard_number); - - if (user?.is_rep) { - return { - // reasons, - training: await this.signInService.getTrainings(user.id, location), - ...user, - ...{infractions: []}, - }; - } - - const extras = await this.signInService.preSignInChecks(location, user.ucard_number); - - // const [trainings, reasons] = await Promise.all([ - // this.trainingService.getUserxxTrainingForLocation(user.username, location), - // this.signInService.getSignInReasons(), - // ]); - return { - // reasons, - training: await this.signInService.getTrainings(user.id, location), - ...user, - ...extras, - }; - } - - @Post("sign-in/:ucard_number") - @IsRep() - @UseInterceptors(IdempotencyCacheInterceptor) - @IdempotencyCache(60) - async signIn( - @Param("location") location: Location, - @Param("ucard_number") ucard_number: string, - @Body() finaliseSignInDto: FinaliseSignInDto, - ) { - this.logger.log(`Signing in UCard number: ${ucard_number} at location: ${location}`, SignInController.name); - const ucard_number_ = ldapLibraryToUcardNumber(ucard_number); - if (await this.signInService.isRep(ucard_number_)) { - return await this.signInService.repSignIn(location, ucard_number_, finaliseSignInDto.reason_id); - } - - return await this.signInService.signIn( - location, - ucard_number_, - finaliseSignInDto.tools, - finaliseSignInDto.reason_id, - ); - } - - @Patch("sign-in/:ucard_number") - @IsRep() - async updateVisitPurpose( - @Param("location") location: Location, - @Param("ucard_number", ParseIntPipe) ucard_number: number, - @Body() updateSignInDto: UpdateSignInDto, - ) { - this.logger.log( - `Updating visit purpose for UCard number: ${ucard_number} at location: ${location}`, - SignInController.name, - ); - return await this.signInService.updateVisitPurpose( - location, - ucard_number, - updateSignInDto.tools, - updateSignInDto.reason_id, - ); - } - - @Post("sign-out/:ucard_number") - @IsRep() - @UseInterceptors(IdempotencyCacheInterceptor) - @IdempotencyCache(60) - async signOut(@Param("location") location: Location, @Param("ucard_number") ucard_number: string) { - this.logger.log(`Signing out UCard number: ${ucard_number} at location: ${location}`, SignInController.name); - return await this.signInService.signOut(location, ldapLibraryToUcardNumber(ucard_number)); - } - - @Get("status") - async getLocationStatus(@Param("location") location: Location): Promise { - this.logger.log(`Retrieving sign-in status for location: ${location}`, SignInController.name); - return await this.signInService.getStatusForLocation(location); - } - - // @Post("queue/remotely") - // @IdempotencyCache(60) - // async addToQueueRemotely(@Param("location") location: Location, @User() user: User_) { - // this.logger.log( - // `Adding user with ID: ${user.id} to queue remotely at location: ${location}`, - // SignInController.name, - // ); - // await this.signInService.addToQueue(location, undefined, user.id); - // } - - @Post("queue/in-person/:ucard_number") - @IsRep() - @UseInterceptors(IdempotencyCacheInterceptor) - @IdempotencyCache(60) - async addToQueueInPerson(@Param("location") location: Location, @Param("ucard_number") ucard_number: string) { - this.logger.log( - `Adding UCard number: ${ucard_number} to queue in-person at location: ${location}`, - SignInController.name, - ); - await this.signInService.addToQueue(location, ucard_number); - } - - @Post("queue/remove/:id") - @IsRep() - @UseInterceptors(IdempotencyCacheInterceptor) - @CheckAbilities(["READ"], "ALL") // FIXME: needs an any rather than all guard also allows for users to remove themselves - @IdempotencyCache(60) - async removeFromQueue(@Param("location") location: Location, @Param("id") user_id: string) { - this.logger.log(`Removing user with ID: ${user_id} from queue at location: ${location}`, SignInController.name); - await this.signInService.removeFromQueue(location, user_id); - } - - // FIXME: events sign in - - @Get("/common-reasons") - @IsRep() - async getPopularSignInReasons(@Param("location") location: Location) { - return this.signInService.getPopularSignInReasons(location); - } + return await this.signInService.signIn( + location, + ucard_number_, + finaliseSignInDto.tools, + finaliseSignInDto.reason_id, + ); + } + + @Patch("sign-in/:ucard_number") + @IsRep() + async updateVisitPurpose( + @Param("location") location: Location, + @Param("ucard_number", ParseIntPipe) ucard_number: number, + @Body() updateSignInDto: UpdateSignInDto, + ) { + this.logger.log( + `Updating visit purpose for UCard number: ${ucard_number} at location: ${location}`, + SignInController.name, + ); + return await this.signInService.updateVisitPurpose( + location, + ucard_number, + updateSignInDto.tools, + updateSignInDto.reason_id, + ); + } + + @Post("sign-out/:ucard_number") + @IsRep() + @UseInterceptors(IdempotencyCacheInterceptor) + @IdempotencyCache(60) + async signOut(@Param("location") location: Location, @Param("ucard_number") ucard_number: string) { + this.logger.log(`Signing out UCard number: ${ucard_number} at location: ${location}`, SignInController.name); + return await this.signInService.signOut(location, ldapLibraryToUcardNumber(ucard_number)); + } + + @Get("status") + async getLocationStatus(@Param("location") location: Location): Promise { + this.logger.log(`Retrieving sign-in status for location: ${location}`, SignInController.name); + return await this.signInService.getStatusForLocation(location); + } + + // @Post("queue/remotely") + // @IdempotencyCache(60) + // async addToQueueRemotely(@Param("location") location: Location, @User() user: User_) { + // this.logger.log( + // `Adding user with ID: ${user.id} to queue remotely at location: ${location}`, + // SignInController.name, + // ); + // await this.signInService.addToQueue(location, undefined, user.id); + // } + + @Post("queue") + @IsRep() + @UseInterceptors(IdempotencyCacheInterceptor) + @IdempotencyCache(60) + async addToQueueInPerson(@Param("location") location: Location, @Body("ucard_number") ucard_number: string) { + this.logger.log( + `Adding UCard number: ${ucard_number} to queue in-person at location: ${location}`, + SignInController.name, + ); + return await this.signInService.addToQueue(location, ucard_number); + } + + @Delete("queue/:id") + @IsRep() + @UseInterceptors(IdempotencyCacheInterceptor) + @CheckAbilities(["READ"], "ALL") // FIXME: needs an any rather than all guard also allows for users to remove themselves + @IdempotencyCache(60) + async removeFromQueue(@Param("location") location: Location, @Param("id") id: string) { + this.logger.log(`Removing queue request with ID: ${id} from queue at location: ${location}`, SignInController.name); + return await this.signInService.removeFromQueue(location, id); + } + + // FIXME: events sign in + + @Get("/common-reasons") + @IsRep() + async getPopularSignInReasons(@Param("location") location: Location) { + return this.signInService.getPopularSignInReasons(location); + } } diff --git a/apps/anvil/src/sign-in/sign-in.service.ts b/apps/anvil/src/sign-in/sign-in.service.ts index ebe4a41..cf9a368 100644 --- a/apps/anvil/src/sign-in/sign-in.service.ts +++ b/apps/anvil/src/sign-in/sign-in.service.ts @@ -1,29 +1,29 @@ -import {EdgeDBService} from "@/edgedb/edgedb.service"; -import {EmailService} from "@/email/email.service"; -import {LdapService} from "@/ldap/ldap.service"; -import {CreateSignInReasonCategoryDto} from "@/root/dto/reason.dto"; -import {ErrorCodes} from "@/shared/constants/ErrorCodes"; -import {sleep} from "@/shared/functions/sleep"; -import {ldapLibraryToUcardNumber} from "@/shared/functions/utils"; -import {PartialUserProps, UserProps, UsersService} from "@/users/users.service"; -import {SignInLocationSchema} from "@dbschema/edgedb-zod/modules/sign_in"; +import { EdgeDBService } from "@/edgedb/edgedb.service"; +import { EmailService } from "@/email/email.service"; +import { LdapService } from "@/ldap/ldap.service"; +import { CreateSignInReasonCategoryDto } from "@/root/dto/reason.dto"; +import { ErrorCodes } from "@/shared/constants/ErrorCodes"; +import { sleep } from "@/shared/functions/sleep"; +import { ldapLibraryToUcardNumber } from "@/shared/functions/utils"; +import { PartialUserProps, UserProps, UsersService } from "@/users/users.service"; +import { SignInLocationSchema } from "@dbschema/edgedb-zod/modules/sign_in"; import e from "@dbschema/edgeql-js"; -import {std} from "@dbschema/interfaces"; -import {getUserTrainingForSignIn} from "@dbschema/queries/getUserTrainingForSignIn.query"; -import {users} from "@ignis/types"; -import type {Location, LocationStatus, Training} from "@ignis/types/sign_in"; -import type {Infraction, InfractionType, PartialUser, User, UserWithInfractions} from "@ignis/types/users"; +import { std } from "@dbschema/interfaces"; +import { getUserTrainingForSignIn } from "@dbschema/queries/getUserTrainingForSignIn.query"; +import { users } from "@ignis/types"; +import type { Location, LocationStatus, QueueEntry, Training } from "@ignis/types/sign_in"; +import type { Infraction, InfractionType, PartialUser, User, UserWithInfractions } from "@ignis/types/users"; import { - BadRequestException, - HttpException, - HttpStatus, - Injectable, - NotFoundException, - OnModuleInit, + BadRequestException, + HttpException, + HttpStatus, + Injectable, + NotFoundException, + OnModuleInit, } from "@nestjs/common"; -import {Logger} from "@nestjs/common"; -import {Cron, CronExpression} from "@nestjs/schedule"; -import {CardinalityViolationError, InvalidValueError} from "edgedb"; +import { Logger } from "@nestjs/common"; +import { Cron, CronExpression } from "@nestjs/schedule"; +import { CardinalityViolationError, InvalidValueError } from "edgedb"; export const REP_ON_SHIFT = "Rep On Shift"; export const REP_OFF_SHIFT = "Rep Off Shift"; @@ -31,818 +31,832 @@ const IN_HOURS_RATIO = 15; const OUT_OF_HOURS_RATIO = 8; export const LOCATIONS = Object.keys(SignInLocationSchema.Values).map((location) => - location.toLocaleLowerCase(), + location.toLocaleLowerCase(), ) as Location[]; function castLocation(location: Location) { - return e.cast(e.sign_in.SignInLocation, location.toUpperCase()); + return e.cast(e.sign_in.SignInLocation, location.toUpperCase()); } function formatInfraction(infraction: Infraction) { - switch (infraction.type) { - case "PERM_BAN": - return `User is permanently banned from the iForge. Reason: ${infraction.reason}`; - case "TEMP_BAN": - return `User is banned from the iForge for ${infraction.duration}. Reason: ${infraction.reason}`; - case "WARNING": - return `User has an unresolved warning. Reason: ${infraction.reason}`; - case "RESTRICTION": - return `User has an unresolved restriction. Reason: ${infraction.reason}`; - case "TRAINING_ISSUE": - return `User has an unresolved training issue. Reason: ${infraction.reason}`; - default: - throw new Error(`Unknown infraction type: ${infraction.type}`); - } + switch (infraction.type) { + case "PERM_BAN": + return `User is permanently banned from the iForge. Reason: ${infraction.reason}`; + case "TEMP_BAN": + return `User is banned from the iForge for ${infraction.duration}. Reason: ${infraction.reason}`; + case "WARNING": + return `User has an unresolved warning. Reason: ${infraction.reason}`; + case "RESTRICTION": + return `User has an unresolved restriction. Reason: ${infraction.reason}`; + case "TRAINING_ISSUE": + return `User has an unresolved training issue. Reason: ${infraction.reason}`; + default: + throw new Error(`Unknown infraction type: ${infraction.type}`); + } } +const QueuePlaceProps = e.shape(e.sign_in.QueuePlace, () => ({ + user: PartialUserProps, + position: true, + created_at: true, + id: true, + can_sign_in: true, +})); + @Injectable() export class SignInService implements OnModuleInit { - private readonly disabledQueue: Set; - private readonly logger: Logger; - - constructor( - private readonly dbService: EdgeDBService, - private readonly userService: UsersService, - private readonly ldapService: LdapService, - private readonly emailService: EmailService, - ) { - this.disabledQueue = new Set(); - this.logger = new Logger(SignInService.name); - } - - async onModuleInit() { - for (const location of LOCATIONS) { - if ((await this.canSignIn(location)) && (await this.queueInUse(location))) { - this.unqueueTop(location); - } - } - } - - async getStatusForLocation(location: Location): Promise { - const [on_shift_rep_count, off_shift_rep_count, total_count, max, can_sign_in, count_in_queue] = await Promise.all([ - this.onShiftReps(location), - this.offShiftReps(location), - this.totalCount(location), - this.maxCount(location), - this.canSignIn(location), - this.countInQueue(location), - this.outOfHours(), - ]); - - const user_count = total_count - off_shift_rep_count - on_shift_rep_count; - - return { - locationName: location, - open: on_shift_rep_count > 0, - on_shift_rep_count, - off_shift_rep_count, - user_count, - max, - out_of_hours: this.outOfHours(), - needs_queue: !can_sign_in, - count_in_queue, - }; - } - - async getList(location: Location) { - try { - return await this.dbService.query( - e.assert_exists( - e.select(e.sign_in.List, (user) => ({ - ...e.sign_in.List["*"], - location: false, - sign_ins: { - ...e.sign_in.SignIn["*"], - location: false, - reason: { - ...e.sign_in.SignInReason["*"], - }, - user: { - ...PartialUserProps(user), - ...e.is(e.users.Rep, { - teams: {name: true, description: true, id: true}, - }), - }, - }, - queued: { - ...e.sign_in.QueuePlace["*"], - location: false, - user: PartialUserProps(user), - }, - filter_single: { - location: castLocation(location), - }, - })), - ), - ); - } catch (error) { - if (error instanceof InvalidValueError) { - throw new NotFoundException(`${location} is not a known location`); - } - throw error; - } - } - - @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) - async signOutAllUsers() { - await this.dbService.query( - e.for( - e.select(e.sign_in.List, () => ({ - sign_ins: true, - })).sign_ins, - (sign_in) => { - return e.update(sign_in, () => ({ - set: { - ends_at: new Date(), - signed_out: true, - }, - })); - }, - ), - ); - } - - async removeUserFromQueueTask(location: Location, user: PartialUser) { - await sleep(1000 * 60 * 15); // This isn't committed to DB but that should be fine - await this.removeFromQueue(location, user.id); - } - - async getUserForSignIn( - location: Location, - ucard_number: string, - ): Promise { - const sign_in = e.select(e.sign_in.SignIn, (sign_in) => ({ - filter_single: e.op( - e.op(sign_in.user.ucard_number, "=", ldapLibraryToUcardNumber(ucard_number)), - "and", - e.op("not", sign_in.signed_out), - ), - })); - let user: - | (User & { - is_rep: boolean; - registered: boolean; - signed_in: boolean; - location?: Uppercase; - teams?: users.ShortTeam[] | null; - }) - | null = await this.dbService.query( - e.select(sign_in.user, (user) => ({ - ...UserProps(user), - is_rep: e.select(e.op(user.__type__.name, "=", "users::Rep")), - registered: e.select(true as boolean), - signed_in: e.select(true as boolean), - location: e.assert_exists(sign_in.location), + private readonly disabledQueue: Set; + private readonly logger: Logger; + + constructor( + private readonly dbService: EdgeDBService, + private readonly userService: UsersService, + private readonly ldapService: LdapService, + private readonly emailService: EmailService, + ) { + this.disabledQueue = new Set(); + this.logger = new Logger(SignInService.name); + } + + async onModuleInit() { + for (const location of LOCATIONS) { + if ((await this.canSignIn(location)) && (await this.queueInUse(location))) { + this.dequeueTop(location); + } + } + } + + async getStatusForLocation(location: Location): Promise { + const [on_shift_rep_count, off_shift_rep_count, total_count, max, can_sign_in, count_in_queue] = await Promise.all([ + this.onShiftReps(location), + this.offShiftReps(location), + this.totalCount(location), + this.maxCount(location), + this.canSignIn(location), + this.countInQueue(location), + this.outOfHours(), + ]); + + const user_count = total_count - off_shift_rep_count - on_shift_rep_count; + + return { + locationName: location, + open: on_shift_rep_count > 0, + on_shift_rep_count, + off_shift_rep_count, + user_count, + max, + out_of_hours: this.outOfHours(), + needs_queue: !can_sign_in, + count_in_queue, + }; + } + + async getList(location: Location) { + try { + return await this.dbService.query( + e.assert_exists( + e.select(e.sign_in.List, (user) => ({ + ...e.sign_in.List["*"], + location: false, + sign_ins: { + ...e.sign_in.SignIn["*"], + location: false, + reason: { + ...e.sign_in.SignInReason["*"], + }, + user: { + ...PartialUserProps(user), ...e.is(e.users.Rep, { - teams: {name: true, description: true, id: true}, + teams: { name: true, description: true, id: true }, }), - })), - ); - if (user?.location && user.location.toLowerCase() !== location) { - throw new BadRequestException({ - message: `User ${ucard_number} is already signed in at a different location, please sign out there before signing in.`, - code: ErrorCodes.already_signed_in_to_location, - }); - } - if (user) { - return user; - } - user = await this.dbService.query( - e.select(e.users.User, (user) => ({ - filter_single: {ucard_number: ldapLibraryToUcardNumber(ucard_number)}, - ...UserProps(user), - is_rep: e.select(e.op(user.__type__.name, "=", "users::Rep")), - registered: e.select(true as boolean), - signed_in: e.select(false as boolean), - ...e.is(e.users.Rep, { - teams: {name: true, description: true, id: true}, - }), - })), - ); - if (user) { - return user; - } - - this.logger.log(`Registering user: ${ucard_number} at location: ${location}`, SignInService.name); - - // no user registered, fetch from ldap - const ldapUser = await this.ldapService.findUserByUcardNumber(ucard_number); - if (!ldapUser) { - throw new NotFoundException({ - message: `User with ucard no ${ucard_number} couldn't be found. Perhaps you made a typo? (it should look like 001739897)`, - code: ErrorCodes.ldap_not_found, - }); - } - - user = await this.dbService.query( - e.select( - e.insert(e.sign_in.UserRegistration, { - location: castLocation(location), - user: e.insert(e.users.User, this.userService.ldapUserProps(ldapUser)), - }).user, - (user) => ({ - ...UserProps(user), - is_rep: e.select(false as boolean), - registered: e.select(false as boolean), - signed_in: e.select(false as boolean), - }), - ), - ); - - await this.emailService.sendWelcomeEmail(user); - return user; - } - - async getTrainings(id: string, location: Location): Promise { - // This cannot work in TS until edgedb/edgedb-js#615 is resolved, instead just use EdgeQL directly - const location_ = location.toUpperCase() as Uppercase; - - const {training} = await getUserTrainingForSignIn(this.dbService.client, { - id, - location: location_, - location_, - on_shift_reasons: this.outOfHours() ? [REP_ON_SHIFT, REP_OFF_SHIFT] : [REP_ON_SHIFT], - }); - const all_training = await this.dbService.query( - e.select(e.training.Training, (training_) => ({ - id: true, - name: true, - compulsory: true, - in_person: true, - rep: {id: true, description: true}, - description: true, - filter: e.all( - e.set( - e.op(training_.id, "not in", e.cast(e.uuid, e.set(...training.map((training) => training.id)))), - e.op("exists", training_.rep), - e.op(e.cast(e.training.TrainingLocation, location_), "in", training_.locations), - ), - ), - })), - ); - return [...all_training, ...training]; - } - - async totalCount(location: Location): Promise { - return await this.dbService.query( - e.count( - e.select(e.sign_in.List, () => ({ - filter_single: { - location: castLocation(location), - }, - })).sign_ins, - ), - ); - } - - outOfHours() { - const date = new Date(); - const current_hour = date.getHours(); - const current_day = date.getDay(); - - return !( - // TODO include term dates here/have some way to set this - (12 <= current_hour && current_hour < 20 && ![0, 6].includes(current_day)) - ); - } - - async maxCount(location: Location): Promise { - const out_of_hours = this.outOfHours(); - let on_shift = await this.onShiftReps(location); - if (out_of_hours) { - // on/off-shift doesn't matter towards the count out of hours it's purely for if the doorbell is outside - on_shift += await this.offShiftReps(location); - } - const factor = out_of_hours ? OUT_OF_HOURS_RATIO : IN_HOURS_RATIO; - return Math.min(on_shift * factor, this.maxPeopleForLocation(location)); - } - - async offShiftReps(location: Location): Promise { - return await this.dbService.query( - e.count( - e.select(e.sign_in.List, () => ({ - sign_ins: (sign_in) => ({ - filter: e.op( - e.op(sign_in.user.__type__.name, "=", "users::Rep"), - "and", - e.op(sign_in.reason.name, "=", REP_OFF_SHIFT), - ), - }), - filter_single: { - location: castLocation(location), - }, - })).sign_ins, - ), - ); - } - - async onShiftReps(location: Location): Promise { - return await this.dbService.query( - e.count( - e.select(e.sign_in.List, () => ({ - sign_ins: (sign_in) => ({ - filter: e.op( - e.op(sign_in.user.__type__.name, "=", "users::Rep"), - "and", - e.op(sign_in.reason.name, "=", REP_ON_SHIFT), - ), - }), - filter_single: { - location: castLocation(location), - }, - })).sign_ins, - ), - ); - } - - async countInQueue(location: Location): Promise { - return await this.dbService.query( - e.count( - e.select(e.sign_in.List, () => ({ - filter_single: { - location: castLocation(location), - }, - })).queued, - ), - ); - } - - async canSignIn(location: Location): Promise { - const total_count = await this.totalCount(location); - - let on_shift = await this.onShiftReps(location); - const off_shift = await this.offShiftReps(location); - - if (this.outOfHours()) { - // on/off-shift doesn't matter towards the count out of hours it's purely for if the doorbell is outside - on_shift += off_shift; - } - - if (total_count >= this.maxPeopleForLocation(location)) { - // the hard cap assuming you're signing in as a user, on shift reps skip this check - return false; - } - - return (await this.maxCount(location)) + on_shift - total_count >= 0; - } - - maxPeopleForLocation(location: Location) { - switch (location.toLowerCase()) { - case "mainspace": - return 45; - case "heartspace": - return 12; - default: - return 0; - } - } - - async isRep(ucard_number: number): Promise { - return await this.dbService.query( - e.op( - "exists", - e.select(e.users.Rep, () => ({ - filter_single: {ucard_number}, - })), - ), - ); - } - - /** - * **Note** - * We don't perform any validation server side to speed up sign ins. There are a couple reasons why: - * - speed - having to refetch all the data or deal with cache invalidation is slow or very hard. - * - data served to the client is validated. - * - the only clients who should be able to insert these are admins. - * - * Full flow: - * - * [![](https://mermaid.ink/img/pako:eNqNlEFvozAQhf_KyOdGveewqzSQhjZBaYC2KenBgklilRjWNl1VJP99bbAD2V7KiYE3730zFjQkK3MkY7IXtDpA7G056GuSRooKNYYooxySKRX5O4xGv-CuCWT3MKqzDKXc1cXvc9d0ZxSnDcoTTNMZquwAiUQBsaCMM76H21tIIn8NQej5Kz_0JmH8PmwNyxO8pb4QpbDJM8oKzK1o2hJ4hqD1XeOeSYUCc0fg9QS-kT3VWCNMMsU-EXalgCX9QCErmuF1i0neuOTWPCzVIMAS-L3_7ILBeJfjHP2L433aCiIsMFNSh2cHxlFas1lv9hOhMXz9hujCrfS-XdG88Uq0dHOqR1_jn5rpMfqj6JbRxTjweQ8UmOmemWQKVrWoSonwTAuWX0sN0otDCvinUTjXS5QlC1qyh8vWJoVAmn9BxPZcgwUc_ELi34PetgsJLiGPzZzaPqtfUIVSWau9QDwiV67xoR8kudrYt1AL99g3LNJpeawKVNiqRv9pDE187enS2_PonG3Lop15mVqjwSdjBctWEKY-z10arERpRFbx1im6YjMsXofFy7BIhkXcFeSGHFEcKcv1t96YV1uiDpp7S8b6NqfiY0u2_Kx1tFZl9MUzMlaixhtSV7letseo_kUcyXhH9Tmd_wHhkVOk?type=png)](https://mermaid.live/edit#pako:eNqNlEFvozAQhf_KyOdGveewqzSQhjZBaYC2KenBgklilRjWNl1VJP99bbAD2V7KiYE3730zFjQkK3MkY7IXtDpA7G056GuSRooKNYYooxySKRX5O4xGv-CuCWT3MKqzDKXc1cXvc9d0ZxSnDcoTTNMZquwAiUQBsaCMM76H21tIIn8NQej5Kz_0JmH8PmwNyxO8pb4QpbDJM8oKzK1o2hJ4hqD1XeOeSYUCc0fg9QS-kT3VWCNMMsU-EXalgCX9QCErmuF1i0neuOTWPCzVIMAS-L3_7ILBeJfjHP2L433aCiIsMFNSh2cHxlFas1lv9hOhMXz9hujCrfS-XdG88Uq0dHOqR1_jn5rpMfqj6JbRxTjweQ8UmOmemWQKVrWoSonwTAuWX0sN0otDCvinUTjXS5QlC1qyh8vWJoVAmn9BxPZcgwUc_ELi34PetgsJLiGPzZzaPqtfUIVSWau9QDwiV67xoR8kudrYt1AL99g3LNJpeawKVNiqRv9pDE187enS2_PonG3Lop15mVqjwSdjBctWEKY-z10arERpRFbx1im6YjMsXofFy7BIhkXcFeSGHFEcKcv1t96YV1uiDpp7S8b6NqfiY0u2_Kx1tFZl9MUzMlaixhtSV7letseo_kUcyXhH9Tmd_wHhkVOk) - */ - async signIn(location: Location, ucard_number: number, tools: string[], reason_id: string) { - const {infractions} = await this.preSignInChecks(location, ucard_number); - if (infractions.length !== 0) { - throw new BadRequestException({ - message: `User ${ucard_number} has active infraction(s):\n${infractions.map(formatInfraction).join("\n")}`, - code: ErrorCodes.user_has_active_infractions, - }); - } - const {reason} = await this.verifySignInReason(reason_id, ucard_number); - - let user: std.BaseObject; - try { - user = await this.dbService.query( - e.insert(e.sign_in.SignIn, { - location: castLocation(location), - user: e.assert_exists( - e.select(e.users.User, () => ({ - filter_single: {ucard_number}, - })), - ), - tools, - reason, - signed_out: false, - }).user, - ); - } catch (error) { - if (error instanceof CardinalityViolationError) { - throw new BadRequestException({ - message: `User ${ucard_number} already signed in`, - code: ErrorCodes.already_signed_in_to_location, - }); - } - throw error; - } - await this.removeFromQueue(location, user.id); - return "Signed in successfully"; - } - - /** Verify that the user can use the sign in reason given by checking their signed agreements - * Ideally this can be removed one day. - */ - async verifySignInReason(reason_id: string, ucard_number: number, is_rep = false) { - const agreements_signed = await this.dbService.query( - e.select(e.users.User, () => ({ - filter_single: {ucard_number}, - })).agreements_signed, - ); - // check for the user agreement - const user_agreement = await this.dbService.query( - e.assert_exists( - e.select(e.sign_in.SignInReason, (reason) => ({ - filter_single: e.op(reason.category, "=", e.sign_in.SignInReasonCategory.PERSONAL_PROJECT), - })).agreement, - ), - ); - if (!agreements_signed.some((agreement) => agreement.id === user_agreement.id)) { - throw new BadRequestException("User agreement not signed."); - } - - const query = e.assert_exists( - e.select(e.sign_in.SignInReason, () => ({ - name: true, - agreement: true, - category: true, - filter_single: {id: reason_id}, - })), - ); - const {name, agreement, category} = await this.dbService.query(query); - - if (category === "REP_SIGN_IN" && !is_rep) { - throw new BadRequestException("User has somehow passed a rep reason"); - } - - if (agreement && !agreements_signed.some((agreement_) => agreement_.id === agreement.id)) { - throw new BadRequestException(`Agreement ${agreement.id} for ${name} not signed.`); - } - - return {reason: query, reason_name: name}; - } - - async preSignInChecks(location: Location, ucard_number: number) { - if (await this.queueInUse(location)) { - await this.assertHasQueued(location, ucard_number); - } else if (!(await this.canSignIn(location))) { - throw new HttpException( - "Failed to sign in, we are at max capacity. Consider using the queue", - HttpStatus.SERVICE_UNAVAILABLE, - ); - } - - const user = await this.dbService.query( - e.select(e.users.User, () => ({ - filter_single: {ucard_number}, - infractions: {type: true, duration: true, reason: true, resolved: true, id: true, created_at: true}, - })), - ); - if (!user) { - throw new NotFoundException({ - message: `User with UCard number ${ucard_number} is not registered`, - code: ErrorCodes.not_registered, - }); - } - const {infractions} = user; - const active_infractions = infractions.filter((infraction) => infraction.resolved); // the final boss of iForge rep'ing enters - return {infractions: active_infractions}; - } - - async repSignIn(location: Location, ucard_number: number, reason_id: string) { - const {reason, reason_name} = await this.verifySignInReason(reason_id, ucard_number, /* is_rep */ true); - - if (reason_name !== REP_ON_SHIFT && !this.outOfHours()) { - await this.preSignInChecks(location, ucard_number); - } - - // TODO this should be client side? - const user = e.assert_exists( - e.select(e.users.Rep, () => ({ - filter_single: {ucard_number}, - })), - ); - - const compulsory = e.select(e.training.Training, (training) => ({ + }, + }, + queued: { + ...e.sign_in.QueuePlace["*"], + location: false, + user: PartialUserProps(user), + }, + filter_single: { + location: castLocation(location), + }, + })), + ), + ); + } catch (error) { + if (error instanceof InvalidValueError) { + throw new NotFoundException(`${location} is not a known location`); + } + throw error; + } + } + + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async signOutAllUsers() { + await this.dbService.query( + e.for( + e.select(e.sign_in.List, () => ({ + sign_ins: true, + })).sign_ins, + (sign_in) => { + return e.update(sign_in, () => ({ + set: { + ends_at: new Date(), + signed_out: true, + }, + })); + }, + ), + ); + } + + async removeUserFromQueueTask(location: Location, user: PartialUser) { + await sleep(1000 * 60 * 15); // This isn't committed to DB but that should be fine + await this.removeFromQueue(location, user.id); + } + + async getUserForSignIn( + location: Location, + ucard_number: string, + ): Promise { + const sign_in = e.select(e.sign_in.SignIn, (sign_in) => ({ + filter_single: e.op( + e.op(sign_in.user.ucard_number, "=", ldapLibraryToUcardNumber(ucard_number)), + "and", + e.op("not", sign_in.signed_out), + ), + })); + let user: + | (User & { + is_rep: boolean; + registered: boolean; + signed_in: boolean; + location?: Uppercase; + teams?: users.ShortTeam[] | null; + }) + | null = await this.dbService.query( + e.select(sign_in.user, (user) => ({ + ...UserProps(user), + is_rep: e.select(e.op(user.__type__.name, "=", "users::Rep")), + registered: e.select(true as boolean), + signed_in: e.select(true as boolean), + location: e.assert_exists(sign_in.location), + ...e.is(e.users.Rep, { + teams: { name: true, description: true, id: true }, + }), + })), + ); + if (user?.location && user.location.toLowerCase() !== location) { + throw new BadRequestException({ + message: `User ${ucard_number} is already signed in at a different location, please sign out there before signing in.`, + code: ErrorCodes.already_signed_in_to_location, + }); + } + if (user) { + return user; + } + user = await this.dbService.query( + e.select(e.users.User, (user) => ({ + filter_single: { ucard_number: ldapLibraryToUcardNumber(ucard_number) }, + ...UserProps(user), + is_rep: e.select(e.op(user.__type__.name, "=", "users::Rep")), + registered: e.select(true as boolean), + signed_in: e.select(false as boolean), + ...e.is(e.users.Rep, { + teams: { name: true, description: true, id: true }, + }), + })), + ); + if (user) { + return user; + } + + this.logger.log(`Registering user: ${ucard_number} at location: ${location}`, SignInService.name); + + // no user registered, fetch from ldap + const ldapUser = await this.ldapService.findUserByUcardNumber(ucard_number); + if (!ldapUser) { + throw new NotFoundException({ + message: `User with ucard no ${ucard_number} couldn't be found. Perhaps you made a typo? (it should look like 001739897)`, + code: ErrorCodes.ldap_not_found, + }); + } + + user = await this.dbService.query( + e.select( + e.insert(e.sign_in.UserRegistration, { + location: castLocation(location), + user: e.insert(e.users.User, this.userService.ldapUserProps(ldapUser)), + }).user, + (user) => ({ + ...UserProps(user), + is_rep: e.select(false as boolean), + registered: e.select(false as boolean), + signed_in: e.select(false as boolean), + }), + ), + ); + + await this.emailService.sendWelcomeEmail(user); + return user; + } + + async getTrainings(id: string, location: Location): Promise { + // This cannot work in TS until edgedb/edgedb-js#615 is resolved, instead just use EdgeQL directly + const location_ = location.toUpperCase() as Uppercase; + + const { training } = await getUserTrainingForSignIn(this.dbService.client, { + id, + location: location_, + location_, + on_shift_reasons: this.outOfHours() ? [REP_ON_SHIFT, REP_OFF_SHIFT] : [REP_ON_SHIFT], + }); + const all_training = await this.dbService.query( + e.select(e.training.Training, (training_) => ({ + id: true, + name: true, + compulsory: true, + in_person: true, + rep: { id: true, description: true }, + description: true, + filter: e.all( + e.set( + e.op(training_.id, "not in", e.cast(e.uuid, e.set(...training.map((training) => training.id)))), + e.op("exists", training_.rep), + e.op(e.cast(e.training.TrainingLocation, location_), "in", training_.locations), + ), + ), + })), + ); + return [...all_training, ...training]; + } + + async totalCount(location: Location): Promise { + return await this.dbService.query( + e.count( + e.select(e.sign_in.List, () => ({ + filter_single: { + location: castLocation(location), + }, + })).sign_ins, + ), + ); + } + + outOfHours() { + const date = new Date(); + const current_hour = date.getHours(); + const current_day = date.getDay(); + + return !( + // TODO include term dates here/have some way to set this + (12 <= current_hour && current_hour < 20 && ![0, 6].includes(current_day)) + ); + } + + async maxCount(location: Location): Promise { + const out_of_hours = this.outOfHours(); + let on_shift = await this.onShiftReps(location); + if (out_of_hours) { + // on/off-shift doesn't matter towards the count out of hours it's purely for if the doorbell is outside + on_shift += await this.offShiftReps(location); + } + const factor = out_of_hours ? OUT_OF_HOURS_RATIO : IN_HOURS_RATIO; + return Math.min(on_shift * factor, this.maxPeopleForLocation(location)); + } + + async offShiftReps(location: Location): Promise { + return await this.dbService.query( + e.count( + e.select(e.sign_in.List, () => ({ + sign_ins: (sign_in) => ({ filter: e.op( - training.compulsory, - "and", - e.op(e.cast(e.training.TrainingLocation, location.toUpperCase()), "in", training.locations), + e.op(sign_in.user.__type__.name, "=", "users::Rep"), + "and", + e.op(sign_in.reason.name, "=", REP_OFF_SHIFT), ), - })); - - const missing = await this.dbService.query( - e.select(e.op(compulsory, "except", user.training), () => ({name: true, id: true})), - ); - - if (missing.length > 0) { - throw new BadRequestException({ - message: `Rep hasn't completed compulsory on shift-trainings. Missing: ${missing - .map((training) => training.name) - .join(", ")}`, - code: ErrorCodes.compulsory_training_missing, - }); - } - - try { - await this.dbService.query( - e.insert(e.sign_in.SignIn, { - location: castLocation(location), - user, - tools: [], - reason, - signed_out: false, - }), - ); - } catch (error) { - if (error instanceof CardinalityViolationError) { - throw new BadRequestException({ - message: `Rep ${ucard_number} already signed in`, - code: ErrorCodes.already_signed_in_to_location, - }); - } - if (error instanceof InvalidValueError) { - throw new BadRequestException( - { - message: `User ${ucard_number} attempting to sign in is not a rep`, - code: ErrorCodes.not_rep, - }, - {cause: error.toString()}, - ); - } - throw error; - } - return "Signed in successfully"; - } - - async updateVisitPurpose( - location: Location, - ucard_number: number, - tools: string[] | undefined, - reason_id: string | undefined, - ) { - const {reason} = reason_id ? await this.verifySignInReason(reason_id, ucard_number) : {reason: undefined}; - - try { - await this.dbService.query( - e.update(e.sign_in.SignIn, (sign_in) => { - const isCorrectLocation = e.op(sign_in.location, "=", castLocation(location)); - const userMatches = e.op(sign_in.user.ucard_number, "=", ucard_number); - const doesNotExist = e.op("not", e.op("exists", sign_in.ends_at)); - - return { - filter_single: e.all(e.set(isCorrectLocation, userMatches, doesNotExist)), - set: { - tools, - reason, - }, - }; - }), - ); - } catch (e) { - if (e instanceof CardinalityViolationError && e.code === 84017154) { - console.log(e, e.code); - throw e; // user not previously signed in - } - throw e; - } - } - - async signOut(location: Location, ucard_number: number) { - try { - await this.dbService.query( - e.assert_exists( - e.update(e.sign_in.SignIn, (sign_in) => { - const isCorrectLocation = e.op(sign_in.location, "=", castLocation(location)); - const userMatches = e.op(sign_in.user.ucard_number, "=", ucard_number); - const doesNotExist = e.op("not", e.op("exists", sign_in.ends_at)); - - return { - filter_single: e.all(e.set(isCorrectLocation, userMatches, doesNotExist)), - set: { - ends_at: new Date(), - signed_out: true, - }, - }; - }), - ), - ); - } catch (error) { - if (error instanceof CardinalityViolationError && error.code === 84017154) { - throw new BadRequestException({ - message: `User ${ucard_number} was not signed in`, - code: ErrorCodes.not_signed_in, - }); - } - throw error; - } - if (await this.canSignIn(location)) { - await this.unqueueTop(location); - } - } - - // queue management - - /* Are there people queuing currently or has it been manually disabled */ - async queueInUse(location: Location) { - if (this.disabledQueue.has(location)) { - throw new HttpException("Queue has been manually disabled", HttpStatus.SERVICE_UNAVAILABLE); - } - const queuing = await this.dbService.query( - e.count( - e.select(e.sign_in.QueuePlace, (place) => ({ - filter: e.op(place.location, "=", castLocation(location)), - limit: 1, - })), + }), + filter_single: { + location: castLocation(location), + }, + })).sign_ins, + ), + ); + } + + async onShiftReps(location: Location): Promise { + return await this.dbService.query( + e.count( + e.select(e.sign_in.List, () => ({ + sign_ins: (sign_in) => ({ + filter: e.op( + e.op(sign_in.user.__type__.name, "=", "users::Rep"), + "and", + e.op(sign_in.reason.name, "=", REP_ON_SHIFT), ), - ); - return queuing > 0 || !(await this.canSignIn(location)); // TODO double check this conditional when less brain fried - } - - async assertHasQueued(location: Location, ucard_number: number) { - const users_can_sign_in = await this.queuedUsersThatCanSignIn(location); - - if ( - !users_can_sign_in.find((user) => { - return user.ucard_number === ucard_number; - }) - ) { - throw new HttpException( - { - message: "Failed to sign in, we are still waiting for people who have been queued to show up", - code: ErrorCodes.not_in_queue, - }, - HttpStatus.SERVICE_UNAVAILABLE, - ); - } - } - - async unqueueTop(location: Location) { - const queuedUsers = await this.dbService.query( - e.select(e.sign_in.QueuePlace, (queue_place) => ({ - user: PartialUserProps(queue_place.user), - filter: e.op( - e.op(queue_place.location, "=", castLocation(location)), - "and", - e.op(queue_place.can_sign_in, "=", false), - ), - limit: 1, - order_by: { - expression: queue_place.position, - direction: e.ASC, - }, + }), + filter_single: { + location: castLocation(location), + }, + })).sign_ins, + ), + ); + } + + async countInQueue(location: Location): Promise { + return await this.dbService.query( + e.count( + e.select(e.sign_in.List, () => ({ + filter_single: { + location: castLocation(location), + }, + })).queued, + ), + ); + } + + async canSignIn(location: Location): Promise { + const total_count = await this.totalCount(location); + + let on_shift = await this.onShiftReps(location); + const off_shift = await this.offShiftReps(location); + + if (this.outOfHours()) { + // on/off-shift doesn't matter towards the count out of hours it's purely for if the doorbell is outside + on_shift += off_shift; + } + + if (total_count >= this.maxPeopleForLocation(location)) { + // the hard cap assuming you're signing in as a user, on shift reps skip this check + return false; + } + + return (await this.maxCount(location)) + on_shift - total_count >= 0; + } + + maxPeopleForLocation(location: Location) { + switch (location.toLowerCase()) { + case "mainspace": + return 45; + case "heartspace": + return 12; + default: + return 0; + } + } + + async isRep(ucard_number: number): Promise { + return await this.dbService.query( + e.op( + "exists", + e.select(e.users.Rep, () => ({ + filter_single: { ucard_number }, + })), + ), + ); + } + + /** + * **Note** + * We don't perform any validation server side to speed up sign ins. There are a couple reasons why: + * - speed - having to refetch all the data or deal with cache invalidation is slow or very hard. + * - data served to the client is validated. + * - the only clients who should be able to insert these are admins. + * + * Full flow: + * + * [![](https://mermaid.ink/img/pako:eNqNlEFvozAQhf_KyOdGveewqzSQhjZBaYC2KenBgklilRjWNl1VJP99bbAD2V7KiYE3730zFjQkK3MkY7IXtDpA7G056GuSRooKNYYooxySKRX5O4xGv-CuCWT3MKqzDKXc1cXvc9d0ZxSnDcoTTNMZquwAiUQBsaCMM76H21tIIn8NQej5Kz_0JmH8PmwNyxO8pb4QpbDJM8oKzK1o2hJ4hqD1XeOeSYUCc0fg9QS-kT3VWCNMMsU-EXalgCX9QCErmuF1i0neuOTWPCzVIMAS-L3_7ILBeJfjHP2L433aCiIsMFNSh2cHxlFas1lv9hOhMXz9hujCrfS-XdG88Uq0dHOqR1_jn5rpMfqj6JbRxTjweQ8UmOmemWQKVrWoSonwTAuWX0sN0otDCvinUTjXS5QlC1qyh8vWJoVAmn9BxPZcgwUc_ELi34PetgsJLiGPzZzaPqtfUIVSWau9QDwiV67xoR8kudrYt1AL99g3LNJpeawKVNiqRv9pDE187enS2_PonG3Lop15mVqjwSdjBctWEKY-z10arERpRFbx1im6YjMsXofFy7BIhkXcFeSGHFEcKcv1t96YV1uiDpp7S8b6NqfiY0u2_Kx1tFZl9MUzMlaixhtSV7letseo_kUcyXhH9Tmd_wHhkVOk?type=png)](https://mermaid.live/edit#pako:eNqNlEFvozAQhf_KyOdGveewqzSQhjZBaYC2KenBgklilRjWNl1VJP99bbAD2V7KiYE3730zFjQkK3MkY7IXtDpA7G056GuSRooKNYYooxySKRX5O4xGv-CuCWT3MKqzDKXc1cXvc9d0ZxSnDcoTTNMZquwAiUQBsaCMM76H21tIIn8NQej5Kz_0JmH8PmwNyxO8pb4QpbDJM8oKzK1o2hJ4hqD1XeOeSYUCc0fg9QS-kT3VWCNMMsU-EXalgCX9QCErmuF1i0neuOTWPCzVIMAS-L3_7ILBeJfjHP2L433aCiIsMFNSh2cHxlFas1lv9hOhMXz9hujCrfS-XdG88Uq0dHOqR1_jn5rpMfqj6JbRxTjweQ8UmOmemWQKVrWoSonwTAuWX0sN0otDCvinUTjXS5QlC1qyh8vWJoVAmn9BxPZcgwUc_ELi34PetgsJLiGPzZzaPqtfUIVSWau9QDwiV67xoR8kudrYt1AL99g3LNJpeawKVNiqRv9pDE187enS2_PonG3Lop15mVqjwSdjBctWEKY-z10arERpRFbx1im6YjMsXofFy7BIhkXcFeSGHFEcKcv1t96YV1uiDpp7S8b6NqfiY0u2_Kx1tFZl9MUzMlaixhtSV7letseo_kUcyXhH9Tmd_wHhkVOk) + */ + async signIn(location: Location, ucard_number: number, tools: string[], reason_id: string) { + const { infractions } = await this.preSignInChecks(location, ucard_number); + if (infractions.length !== 0) { + throw new BadRequestException({ + message: `User ${ucard_number} has active infraction(s):\n${infractions.map(formatInfraction).join("\n")}`, + code: ErrorCodes.user_has_active_infractions, + }); + } + const { reason } = await this.verifySignInReason(reason_id, ucard_number); + + let user: std.BaseObject; + try { + user = await this.dbService.query( + e.insert(e.sign_in.SignIn, { + location: castLocation(location), + user: e.assert_exists( + e.select(e.users.User, () => ({ + filter_single: { ucard_number }, })), - ); - if (queuedUsers.length !== 0) { - const topUser = queuedUsers[0]; - await this.emailService.sendUnqueuedEmail(topUser.user, location); - this.removeUserFromQueueTask(location, topUser.user).catch(); - } - } - - async addToQueue(location: Location, ucard_number: string) { - if (!(await this.queueInUse(location))) { - throw new HttpException("The queue is currently not in use", HttpStatus.BAD_REQUEST); - } - - try { - return await this.dbService.query( - e.insert(e.sign_in.QueuePlace, { - user: e.select(e.users.User, () => ({ - filter_single: {ucard_number: ldapLibraryToUcardNumber(ucard_number)}, - })), - location: castLocation(location), - position: e.op(e.count(e.select(e.sign_in.QueuePlace)), "+", 1), - }), - ); - } catch (e) { - if (e instanceof CardinalityViolationError && e.code === 84017154) { - console.log(e, e.code); - throw new HttpException("The user is already in the queue", HttpStatus.BAD_REQUEST); - } - console.log(e); - throw e; - } - } - - async removeFromQueue(location: Location, id: string) { - // again, user_id because might not have ucard_number - if (await this.queueInUse(location)) { - throw new HttpException("The queue is currently not in use", HttpStatus.BAD_REQUEST); - } - - await this.dbService.client.transaction(async (tx) => { - await e - .delete(e.sign_in.QueuePlace, (queue_place) => ({ - filter: e.op(queue_place.user.id, "=", e.uuid(id)), - })) - .run(tx); - - await e - .update(e.sign_in.QueuePlace, (queue_place) => ({ - filter: e.op(queue_place.location, "=", castLocation(location)), - set: { - position: e.op(queue_place.position, "-", 1), - }, - })) - .run(tx); + ), + tools, + reason, + signed_out: false, + }).user, + ); + } catch (error) { + if (error instanceof CardinalityViolationError) { + throw new BadRequestException({ + message: `User ${ucard_number} already signed in`, + code: ErrorCodes.already_signed_in_to_location, }); - } - - async queuedUsersThatCanSignIn(location: Location) { - return await this.dbService.query( - e.select( - e.select(e.sign_in.QueuePlace, (queue_place) => ({ - filter: e.op( - e.op(queue_place.location, "=", e.cast(e.sign_in.SignInLocation, castLocation(location))), - "and", - e.op(queue_place.can_sign_in, "=", true), - ), - })).user, - PartialUserProps, - ), - ); - } - - async getSignInReasons() { - // TODO in future would be nice to only return options that a user is part of due to - // - Their course - // - SU clubs - // - CCAs - return await this.dbService.query( - e.select(e.sign_in.SignInReason, () => ({...e.sign_in.SignInReason["*"], agreement: true})), - ); - } - - async addSignInReason(reason: CreateSignInReasonCategoryDto) { - return await this.dbService.query( - e.select( - e.insert(e.sign_in.SignInReason, { - ...reason, // for some reason this works but just passing it directly doesn't - }), - () => e.sign_in.SignInReason["*"], - ), + } + throw error; + } + await this.removeFromQueue(location, user.id); + return "Signed in successfully"; + } + + /** Verify that the user can use the sign in reason given by checking their signed agreements + * Ideally this can be removed one day. + */ + async verifySignInReason(reason_id: string, ucard_number: number, is_rep = false) { + const agreements_signed = await this.dbService.query( + e.select(e.users.User, () => ({ + filter_single: { ucard_number }, + })).agreements_signed, + ); + // check for the user agreement + const user_agreement = await this.dbService.query( + e.assert_exists( + e.select(e.sign_in.SignInReason, (reason) => ({ + filter_single: e.op(reason.category, "=", e.sign_in.SignInReasonCategory.PERSONAL_PROJECT), + })).agreement, + ), + ); + if (!agreements_signed.some((agreement) => agreement.id === user_agreement.id)) { + throw new BadRequestException("User agreement not signed."); + } + + const query = e.assert_exists( + e.select(e.sign_in.SignInReason, () => ({ + name: true, + agreement: true, + category: true, + filter_single: { id: reason_id }, + })), + ); + const { name, agreement, category } = await this.dbService.query(query); + + if (category === "REP_SIGN_IN" && !is_rep) { + throw new BadRequestException("User has somehow passed a rep reason"); + } + + if (agreement && !agreements_signed.some((agreement_) => agreement_.id === agreement.id)) { + throw new BadRequestException(`Agreement ${agreement.id} for ${name} not signed.`); + } + + return { reason: query, reason_name: name }; + } + + async preSignInChecks(location: Location, ucard_number: number) { + if (await this.queueInUse(location)) { + await this.assertHasQueued(location, ucard_number); + } else if (!(await this.canSignIn(location))) { + throw new HttpException( + "Failed to sign in, we are at max capacity. Consider using the queue", + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + const user = await this.dbService.query( + e.select(e.users.User, () => ({ + filter_single: { ucard_number }, + infractions: { type: true, duration: true, reason: true, resolved: true, id: true, created_at: true }, + })), + ); + if (!user) { + throw new NotFoundException({ + message: `User with UCard number ${ucard_number} is not registered`, + code: ErrorCodes.not_registered, + }); + } + const { infractions } = user; + const active_infractions = infractions.filter((infraction) => infraction.resolved); // the final boss of iForge rep'ing enters + return { infractions: active_infractions }; + } + + async repSignIn(location: Location, ucard_number: number, reason_id: string) { + const { reason, reason_name } = await this.verifySignInReason(reason_id, ucard_number, /* is_rep */ true); + + if (reason_name !== REP_ON_SHIFT && !this.outOfHours()) { + await this.preSignInChecks(location, ucard_number); + } + + // TODO this should be client side? + const user = e.assert_exists( + e.select(e.users.Rep, () => ({ + filter_single: { ucard_number }, + })), + ); + + const compulsory = e.select(e.training.Training, (training) => ({ + filter: e.op( + training.compulsory, + "and", + e.op(e.cast(e.training.TrainingLocation, location.toUpperCase()), "in", training.locations), + ), + })); + + const missing = await this.dbService.query( + e.select(e.op(compulsory, "except", user.training), () => ({ name: true, id: true })), + ); + + if (missing.length > 0) { + throw new BadRequestException({ + message: `Rep hasn't completed compulsory on shift-trainings. Missing: ${missing + .map((training) => training.name) + .join(", ")}`, + code: ErrorCodes.compulsory_training_missing, + }); + } + + try { + await this.dbService.query( + e.insert(e.sign_in.SignIn, { + location: castLocation(location), + user, + tools: [], + reason, + signed_out: false, + }), + ); + } catch (error) { + if (error instanceof CardinalityViolationError) { + throw new BadRequestException({ + message: `Rep ${ucard_number} already signed in`, + code: ErrorCodes.already_signed_in_to_location, + }); + } + if (error instanceof InvalidValueError) { + throw new BadRequestException( + { + message: `User ${ucard_number} attempting to sign in is not a rep`, + code: ErrorCodes.not_rep, + }, + { cause: error.toString() }, ); - } - - async deleteSignInReason(id: string) { - return await this.dbService.query( - e.delete(e.sign_in.SignInReason, () => ({ - filter_single: {id}, + } + throw error; + } + return "Signed in successfully"; + } + + async updateVisitPurpose( + location: Location, + ucard_number: number, + tools: string[] | undefined, + reason_id: string | undefined, + ) { + const { reason } = reason_id ? await this.verifySignInReason(reason_id, ucard_number) : { reason: undefined }; + + try { + await this.dbService.query( + e.update(e.sign_in.SignIn, (sign_in) => { + const isCorrectLocation = e.op(sign_in.location, "=", castLocation(location)); + const userMatches = e.op(sign_in.user.ucard_number, "=", ucard_number); + const doesNotExist = e.op("not", e.op("exists", sign_in.ends_at)); + + return { + filter_single: e.all(e.set(isCorrectLocation, userMatches, doesNotExist)), + set: { + tools, + reason, + }, + }; + }), + ); + } catch (e) { + if (e instanceof CardinalityViolationError && e.code === 84017154) { + console.log(e, e.code); + throw e; // user not previously signed in + } + throw e; + } + } + + async signOut(location: Location, ucard_number: number) { + try { + await this.dbService.query( + e.assert_exists( + e.update(e.sign_in.SignIn, (sign_in) => { + const isCorrectLocation = e.op(sign_in.location, "=", castLocation(location)); + const userMatches = e.op(sign_in.user.ucard_number, "=", ucard_number); + const doesNotExist = e.op("not", e.op("exists", sign_in.ends_at)); + + return { + filter_single: e.all(e.set(isCorrectLocation, userMatches, doesNotExist)), + set: { + ends_at: new Date(), + signed_out: true, + }, + }; + }), + ), + ); + } catch (error) { + if (error instanceof CardinalityViolationError && error.code === 84017154) { + throw new BadRequestException({ + message: `User ${ucard_number} was not signed in`, + code: ErrorCodes.not_signed_in, + }); + } + throw error; + } + if (await this.canSignIn(location)) { + await this.dequeueTop(location); + } + } + + // queue management + + /* Are there people queuing currently or has it been manually disabled */ + async queueInUse(location: Location) { + if (this.disabledQueue.has(location)) { + throw new HttpException("Queue has been manually disabled", HttpStatus.SERVICE_UNAVAILABLE); + } + const queuing = await this.dbService.query( + e.count( + e.select(e.sign_in.QueuePlace, (place) => ({ + filter: e.op(place.location, "=", castLocation(location)), + limit: 1, + })), + ), + ); + return queuing > 0 || !(await this.canSignIn(location)); + } + + async assertHasQueued(location: Location, ucard_number: number) { + const users_can_sign_in = await this.queuedUsersThatCanSignIn(location); + + if ( + !users_can_sign_in.find((user) => { + return user.ucard_number === ucard_number; + }) + ) { + throw new HttpException( + { + message: "Failed to sign in, we are still waiting for people who have been queued to show up", + code: ErrorCodes.not_in_queue, + }, + HttpStatus.BAD_REQUEST, + ); + } + } + + async dequeueTop(location: Location) { + const queuedUsers = await this.dbService.query( + e.select(e.sign_in.QueuePlace, (queue_place) => ({ + user: PartialUserProps(queue_place.user), + filter: e.op( + e.op(queue_place.location, "=", castLocation(location)), + "and", + e.op(queue_place.can_sign_in, "=", false), + ), + limit: 1, + order_by: { + expression: queue_place.position, + direction: e.ASC, + }, + })), + ); + if (queuedUsers.length !== 0) { + const topUser = queuedUsers[0]; + await this.emailService.sendUnqueuedEmail(topUser, location); + this.removeUserFromQueueTask(location, topUser.user).catch(); + } + } + + async addToQueue(location: Location, ucard_number: string) { + if (!(await this.queueInUse(location))) { + throw new HttpException("The queue is currently not in use", HttpStatus.BAD_REQUEST); + } + + let place: QueueEntry; + + try { + place = await this.dbService.query( + e.select( + e.insert(e.sign_in.QueuePlace, { + user: e.select(e.users.User, () => ({ + filter_single: { ucard_number: ldapLibraryToUcardNumber(ucard_number) }, })), - ); - } - - async getSignInReasonsLastUpdate() { - return await this.dbService.query( - e.assert_exists( - e.assert_single( - e.select(e.sign_in.SignInReason, (sign_in) => ({ - order_by: { - expression: sign_in.created_at, - direction: e.DESC, - }, - limit: 1, - })).created_at, - ), - ), - ); - } - - async getPopularSignInReasons(location: Location) { - return await this.dbService.query( - e.select( - e.group( - e.select(e.sign_in.SignIn, (sign_in) => ({ - filter: e.op( - e.op(sign_in.created_at, "<", e.op(e.datetime_current(), "-", e.cal.relative_duration("3d"))), - "and", - e.op(sign_in.location, "=", castLocation(location)), - ), - })), - (sign_in) => ({ - by: {reason: sign_in.reason}, - }), - ).elements.reason, - () => ({limit: 5, id: true, name: true, category: true}), + location: castLocation(location), + position: e.op(e.count(e.select(e.sign_in.QueuePlace)), "+", 1), + }), + QueuePlaceProps, + ), + ); + } catch (e) { + if (e instanceof CardinalityViolationError && e.code === 84017154) { + console.log(e, e.code); + throw new HttpException("The user is already in the queue", HttpStatus.BAD_REQUEST); + } + console.log(e); + throw e; + } + await this.emailService.sendQueuedEmail(place, location); + return place; + } + + async removeFromQueue(location: Location, id: string) { + if (await this.queueInUse(location)) { + throw new HttpException("The queue is currently not in use", HttpStatus.BAD_REQUEST); + } + + await this.dbService.client.transaction(async (tx) => { + await e + .delete(e.sign_in.QueuePlace, () => ({ + filter_single: { id }, + })) + .run(tx); + + await e + .update(e.sign_in.QueuePlace, (queue_place) => ({ + filter: e.op(queue_place.location, "=", castLocation(location)), + set: { + position: e.op(queue_place.position, "-", 1), + }, + })) + .run(tx); + }); + } + + async queuedUsersThatCanSignIn(location: Location) { + return await this.dbService.query( + e.select( + e.select(e.sign_in.QueuePlace, (queue_place) => ({ + filter: e.op( + e.op(queue_place.location, "=", e.cast(e.sign_in.SignInLocation, castLocation(location))), + "and", + e.op(queue_place.can_sign_in, "=", true), + ), + })).user, + PartialUserProps, + ), + ); + } + + async getSignInReasons() { + // TODO in future would be nice to only return options that a user is part of due to + // - Their course + // - SU clubs + // - CCAs + return await this.dbService.query( + e.select(e.sign_in.SignInReason, () => ({ ...e.sign_in.SignInReason["*"], agreement: true })), + ); + } + + async addSignInReason(reason: CreateSignInReasonCategoryDto) { + return await this.dbService.query( + e.select( + e.insert(e.sign_in.SignInReason, { + ...reason, // for some reason this works but just passing it directly doesn't + }), + () => e.sign_in.SignInReason["*"], + ), + ); + } + + async deleteSignInReason(id: string) { + return await this.dbService.query( + e.delete(e.sign_in.SignInReason, () => ({ + filter_single: { id }, + })), + ); + } + + async getSignInReasonsLastUpdate() { + return await this.dbService.query( + e.assert_exists( + e.assert_single( + e.select(e.sign_in.SignInReason, (sign_in) => ({ + order_by: { + expression: sign_in.created_at, + direction: e.DESC, + }, + limit: 1, + })).created_at, + ), + ), + ); + } + + async getPopularSignInReasons(location: Location) { + return await this.dbService.query( + e.select( + e.group( + e.select(e.sign_in.SignIn, (sign_in) => ({ + filter: e.op( + e.op(sign_in.created_at, "<", e.op(e.datetime_current(), "-", e.cal.relative_duration("3d"))), + "and", + e.op(sign_in.location, "=", castLocation(location)), ), - ); - } + })), + (sign_in) => ({ + by: { reason: sign_in.reason }, + }), + ).elements.reason, + () => ({ limit: 5, id: true, name: true, category: true }), + ), + ); + } } diff --git a/apps/forge/src/components/signin/actions/QueueDispatcher.tsx b/apps/forge/src/components/signin/actions/QueueDispatcher.tsx index 74d8e14..4fd76d2 100644 --- a/apps/forge/src/components/signin/actions/QueueDispatcher.tsx +++ b/apps/forge/src/components/signin/actions/QueueDispatcher.tsx @@ -5,7 +5,7 @@ import { useDispatch, useSelector } from "react-redux"; import { errorDisplay } from "@/components/errors/ErrorDisplay"; import { signinActions } from "@/redux/signin.slice.ts"; -import { PostQueueInPerson, PostQueueProps } from "@/services/signin/queueService.ts"; +import { PostQueue, PostQueueProps } from "@/services/signin/queueService.ts"; import { FlowStepComponent } from "@/types/signInActions.ts"; import { useNavigate } from "@tanstack/react-router"; import { Button } from "@ui/components/ui/button.tsx"; @@ -31,7 +31,7 @@ const QueueDispatcher: FlowStepComponent = ({ onSecondary, onPrimary }) => { const { isPending, error, mutate } = useMutation({ mutationKey: ["postQueueInPerson", queueProps], - mutationFn: () => PostQueueInPerson(queueProps), + mutationFn: () => PostQueue(queueProps), retry: 0, onError: (error) => { console.log("Error", error); diff --git a/apps/forge/src/components/signin/actions/SigningInUserCard.tsx b/apps/forge/src/components/signin/actions/SigningInUserCard.tsx index b2414a1..0cc5598 100644 --- a/apps/forge/src/components/signin/actions/SigningInUserCard.tsx +++ b/apps/forge/src/components/signin/actions/SigningInUserCard.tsx @@ -11,7 +11,7 @@ export default function SigningInUserCard({ user }: { user: sign_in.User }) {
- +

{user.display_name}

diff --git a/apps/forge/src/components/signin/dashboard/components/QueuedUserCard.tsx b/apps/forge/src/components/signin/dashboard/components/QueuedUserCard.tsx new file mode 100644 index 0000000..c8df4d6 --- /dev/null +++ b/apps/forge/src/components/signin/dashboard/components/QueuedUserCard.tsx @@ -0,0 +1,98 @@ +import { UserAvatar } from "@/components/avatar"; +import { iForgeEpoch } from "@/config/constants"; +import { useUser } from "@/lib/utils"; +import { AppRootState } from "@/redux/store"; +import { DeleteQueue } from "@/services/signin/queueService"; +import { QueueEntry } from "@ignis/types/sign_in"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { Button } from "@ui/components/ui/button"; +import { Card } from "@ui/components/ui/card"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@ui/components/ui/tooltip"; +import { Delete } from "lucide-react"; +import { useSelector } from "react-redux"; +import { toast } from "sonner"; +import { AdminDisplay } from "./SignedInUserCard/AdminDisplay"; +import { TimeDisplay } from "./SignedInUserCard/TimeDisplay"; + +interface QueuedUserCardProps { + place: QueueEntry; +} + +export const QueuedUserCard: React.FC = ({ place }) => { + const isAdmin = useUser()!.roles.find((role) => role.name === "Admin"); + const activeLocation = useSelector((state: AppRootState) => state.signin.active_location); + const abortController = new AbortController(); + const queryClient = useQueryClient(); + const dequeueProps = { + locationName: activeLocation, + id: place.id, + }; + + const { mutate } = useMutation({ + mutationKey: ["postDequeue", dequeueProps], + mutationFn: () => DeleteQueue(dequeueProps), + retry: 0, + onError: (error) => { + console.error("Error", error); + abortController.abort(); + }, + onSuccess: () => { + abortController.abort(); + toast.success(`Successfully signed out ${place.user.display_name}`); + queryClient.invalidateQueries({ queryKey: ["locationStatus", "locationList", { activeLocation }] }); + }, + }); + + const handleDequeue = () => { + if (window.confirm("Are you sure you want to remove this user from the queue?")) { + mutate(); + } + }; + return ( + +
+
+
+ +

{place.user.display_name}

+ +
+
+ +
+
+
+ {isAdmin && } + <> +
+
+
+ {place.can_sign_in ? "Can" : "Cannot"} sign in +
+
+
+ + +
+ + + + + + +

Remove the user from the queue

+
+
+
+
+
+ ); +}; diff --git a/apps/forge/src/components/signin/dashboard/components/SignedInUserCard/SignInReasonDisplay.tsx b/apps/forge/src/components/signin/dashboard/components/SignedInUserCard/SignInReasonDisplay.tsx index 80d6821..7247ec0 100644 --- a/apps/forge/src/components/signin/dashboard/components/SignedInUserCard/SignInReasonDisplay.tsx +++ b/apps/forge/src/components/signin/dashboard/components/SignedInUserCard/SignInReasonDisplay.tsx @@ -15,7 +15,7 @@ export const SignInReasonWithToolsDisplay: React.FC Sign In Reason
- +
diff --git a/apps/forge/src/components/signin/dashboard/components/SignedInUserCard/TimeDisplay.tsx b/apps/forge/src/components/signin/dashboard/components/SignedInUserCard/TimeDisplay.tsx index 574ac28..6018c44 100644 --- a/apps/forge/src/components/signin/dashboard/components/SignedInUserCard/TimeDisplay.tsx +++ b/apps/forge/src/components/signin/dashboard/components/SignedInUserCard/TimeDisplay.tsx @@ -1,13 +1,15 @@ +import { iForgeEpoch } from "@/config/constants.ts"; import { Badge } from "@ui/components/ui/badge.tsx"; -import * as React from "react"; import { format, intervalToDuration } from "date-fns"; -import { iForgeEpoch } from "@/config/constants.ts"; +import * as React from "react"; interface TimeDisplayProps { timeIn: Date; + inText?: string; + durationText?: string; } -export const TimeDisplay: React.FC = ({ timeIn }) => { +export const TimeDisplay: React.FC = ({ timeIn, inText, durationText }) => { const [duration, setDuration] = React.useState(""); React.useEffect(() => { const intervalId = setInterval(() => { @@ -29,12 +31,14 @@ export const TimeDisplay: React.FC = ({ timeIn }) => {
- Time In: {formattedTime} + {inText ?? "Time In:"}{" "} + {formattedTime}
- Duration: {duration} + {durationText ?? "Duration:"}{" "} + {duration}
diff --git a/apps/forge/src/components/signin/dashboard/components/SignedInUserCard/index.tsx b/apps/forge/src/components/signin/dashboard/components/SignedInUserCard/index.tsx index ac6b2a6..ec8e753 100644 --- a/apps/forge/src/components/signin/dashboard/components/SignedInUserCard/index.tsx +++ b/apps/forge/src/components/signin/dashboard/components/SignedInUserCard/index.tsx @@ -81,7 +81,7 @@ export const SignedInUserCard: React.FunctionComponent = ({
- +

{user.display_name}

diff --git a/apps/forge/src/components/signin/dashboard/index.tsx b/apps/forge/src/components/signin/dashboard/index.tsx index 1a38271..b333a87 100644 --- a/apps/forge/src/components/signin/dashboard/index.tsx +++ b/apps/forge/src/components/signin/dashboard/index.tsx @@ -1,3 +1,4 @@ +import { useAuth } from "@/components/auth-provider"; import ActiveLocationSelector from "@/components/signin/ActiveLocationSelector"; import { SignInDrawer } from "@/components/signin/dashboard/components/SignInDrawer.tsx"; import { SignedInUserCard } from "@/components/signin/dashboard/components/SignedInUserCard"; @@ -7,14 +8,14 @@ import { extractError } from "@/lib/utils"; import { AppRootState } from "@/redux/store.ts"; import { dataForLocation } from "@/services/signin/locationService.ts"; import type { QueueEntry, SignInEntry } from "@ignis/types/sign_in"; +import { PartialUserWithTeams } from "@ignis/types/users.ts"; import { ExclamationTriangleIcon, InfoCircledIcon } from "@radix-ui/react-icons"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Alert, AlertDescription, AlertTitle } from "@ui/components/ui/alert.tsx"; import { Loader } from "@ui/components/ui/loader.tsx"; import { useEffect, useState } from "react"; import { useSelector } from "react-redux"; -import { PartialUserWithTeams } from "@ignis/types/users.ts"; -import { useAuth } from "@/components/auth-provider"; +import { QueuedUserCard } from "./components/QueuedUserCard"; export default function SignInDashboard() { const queryClient = useQueryClient(); @@ -132,9 +133,7 @@ export default function SignInDashboard() { )} {queuedUsers.length > 0 && - queuedUsers.map((entry) => { - return ; - })} + queuedUsers.map((entry) => )}
diff --git a/apps/forge/src/components/ucard-reader/index.tsx b/apps/forge/src/components/ucard-reader/index.tsx index 1c6f71c..2230da5 100644 --- a/apps/forge/src/components/ucard-reader/index.tsx +++ b/apps/forge/src/components/ucard-reader/index.tsx @@ -2,6 +2,7 @@ import { signinActions } from "@/redux/signin.slice"; import { AppDispatch, AppRootState } from "@/redux/store"; import { GetSignIn, PostSignOut } from "@/services/signin/signInService"; import { User } from "@ignis/types/sign_in"; +import { useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate } from "@tanstack/react-router"; import { Button } from "@ui/components/ui/button"; import { useEffect, useState } from "react"; @@ -13,6 +14,7 @@ export default function UCardReader() { const activeLocation = useSelector((state: AppRootState) => state.signin.active_location); const dispatch: AppDispatch = useDispatch(); const navigate = useNavigate(); + const queryClient = useQueryClient(); useEffect(() => { const down = (e: KeyboardEvent) => { @@ -76,6 +78,8 @@ export default function UCardReader() { description: (e as any).toString(), }); } + + queryClient.invalidateQueries({ queryKey: ["locationStatus", "locationList", { activeLocation }] }); toast.success(`Successfully signed out ${uCardNumber}`); }), ); diff --git a/apps/forge/src/services/signin/queueService.ts b/apps/forge/src/services/signin/queueService.ts index 71accbe..baed2be 100644 --- a/apps/forge/src/services/signin/queueService.ts +++ b/apps/forge/src/services/signin/queueService.ts @@ -1,17 +1,18 @@ import axiosInstance from "@/api/axiosInstance.ts"; +import { Location } from "@ignis/types/sign_in"; import axios from "axios"; export interface PostQueueProps { signal: AbortSignal; - locationName: string; + locationName: Location; uCardNumber: string; } -export const PostQueueInPerson = async ({ locationName, uCardNumber, signal }: PostQueueProps): Promise => { +export const PostQueue = async ({ locationName, uCardNumber, signal }: PostQueueProps): Promise => { try { const { data } = await axiosInstance.post( - `/location/${locationName}/queue/in-person/${uCardNumber}`, - {}, + `/location/${locationName}/queue`, + { ucard_number: uCardNumber }, { signal: signal }, ); return data; @@ -19,7 +20,27 @@ export const PostQueueInPerson = async ({ locationName, uCardNumber, signal }: P if (axios.isAxiosError(error) && error.response) { // This is an API error console.error("API error occurred while posting to /queue/in-person:", error.response.data); - throw new Error(error.response.data.message || "An error occurred with the API."); + throw error; + } + // This is an Axios error (network problem, etc.) + throw error; + } +}; + +export interface DeleteQueueProps { + locationName: Location; + id: string; +} + +export const DeleteQueue = async ({ locationName, id }: DeleteQueueProps): Promise => { + try { + const { data } = await axiosInstance.delete(`/location/${locationName}/queue/${id}`); + return data; + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + // This is an API error + console.error("API error occurred while posting to /queue/in-person:", error.response.data); + throw error; } // This is an Axios error (network problem, etc.) throw error; diff --git a/packages/types/sign_in.ts b/packages/types/sign_in.ts index 9e954c3..b00c950 100644 --- a/packages/types/sign_in.ts +++ b/packages/types/sign_in.ts @@ -28,6 +28,7 @@ export type QueueEntry = { user: users.PartialUser; position: number; created_at: Date; + can_sign_in: boolean; }; /** The type of the location/:location endpoint storing data on who's logged in for the dashboard */