diff --git a/apps/anvil/src/sign-in/sign-in.controller.ts b/apps/anvil/src/sign-in/sign-in.controller.ts index d63bd71..6013fff 100644 --- a/apps/anvil/src/sign-in/sign-in.controller.ts +++ b/apps/anvil/src/sign-in/sign-in.controller.ts @@ -1,5 +1,5 @@ import {CheckAbilities} from "@/auth/authorization/decorators/check-abilities-decorator"; -import {IsRep} from "@/auth/authorization/decorators/check-roles-decorator"; +import {IsAdmin,IsRep} from "@/auth/authorization/decorators/check-roles-decorator"; import {CaslAbilityGuard} from "@/auth/authorization/guards/casl-ability.guard"; import {TrainingService} from "@/training/training.service"; import {UsersService} from "@/users/users.service"; @@ -119,34 +119,17 @@ export class SignInController { } @Get("status") - async getSignInStatus(@Param("location") location: Location): Promise { + 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() - @IdempotencyCache(60) - async addToQueueInPerson( - @Param("location") location: Location, - @Param("ucard_number", ParseIntPipe) ucard_number: number, - ) { - 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/add/:id") + @IdempotencyCache(60) + async addToQueueInPerson(@Param("location") location: Location, @Param("id") id: string) { + this.logger.log(`Adding user ${id} to queue at location: ${location}`, SignInController.name); + return await this.signInService.addToQueue(location, id); + } @Post("queue/remove/:id") @IsRep() diff --git a/apps/anvil/src/sign-in/sign-in.service.ts b/apps/anvil/src/sign-in/sign-in.service.ts index ee60409..559681e 100644 --- a/apps/anvil/src/sign-in/sign-in.service.ts +++ b/apps/anvil/src/sign-in/sign-in.service.ts @@ -1,27 +1,28 @@ -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 {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 { 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 type {Location, LocationStatus, Training} from "@ignis/types/sign_in"; -import type {PartialUser, User} 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, Training } from "@ignis/types/sign_in"; +import type { PartialUser, User } 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"; import {ldapLibraryToUcardNumber} from "@/shared/functions/utils"; export const REP_ON_SHIFT = "Rep On Shift"; @@ -30,738 +31,795 @@ 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()); } @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 { - let 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), - })), - ); - 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) + 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), + ...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( + e.op(sign_in.user.__type__.name, "=", "users::Rep"), + "and", + e.op(sign_in.reason.name, "=", REP_OFF_SHIFT), ), - ); - - 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 infractions ${infractions}`, - 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) => ({ + }), + 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( - 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_ON_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.select(e.sign_in.QueuePlace, (place) => ({ - filter: e.op(place.location, "=", castLocation(location)), - limit: 1, - })), - ); - return queuing.length > 0; - } - - 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 infractions ${infractions}`, + 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) => ({ + 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() }, ); - 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: number): Promise; - async addToQueue(location: Location, ucard_number: undefined, user_id: string): Promise; - async addToQueue(location: Location, ucard_number: number | undefined, user_id: string | undefined = undefined) { - if (!(await this.queueInUse(location))) { - throw new HttpException("The queue is currently not in use", HttpStatus.BAD_REQUEST); - } - - try { - e.insert(e.sign_in.QueuePlace, { - user: e.select(e.users.User, () => ({ - filter_single: ucard_number ? {ucard_number} : {id: user_id!}, // on the off chance the user hasn't been registered yet, use their ID - })), - 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, user_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.SERVICE_UNAVAILABLE); - } - - 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(user_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); + } + 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; } - - 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, - ), - ); + if (await this.canSignIn(location)) { + await this.unqueueTop(location); } + } - 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})), - ); - } + // queue management - 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["*"], - ), - ); + /* 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.select(e.sign_in.QueuePlace, (place) => ({ + filter: e.op(place.location, "=", castLocation(location)), + limit: 1, + })), + ); + return queuing.length > 0; + } - async deleteSignInReason(id: string) { - return await this.dbService.query( - e.delete(e.sign_in.SignInReason, () => ({ - filter_single: {id}, - })), - ); - } + async assertHasQueued(location: Location, ucard_number: number) { + const users_can_sign_in = await this.queuedUsersThatCanSignIn(location); - 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, - ), - ), - ); - } + 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, + }, + })), + ); + 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, id: 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: { id }, + })), + 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.SERVICE_UNAVAILABLE); + } + + 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); + }); + } + + 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() { + return await this.dbService.query( + e.select( + e.group( + e.select(e.sign_in.SignIn, (sign_in) => ({ + filter: e.op(sign_in.created_at, "<", e.op(e.datetime_current(), "-", e.cal.relative_duration("3d"))), + })), + (sign_in) => ({ + by: { reason: sign_in.reason }, + }), + ).elements.reason, + () => ({ limit: 5 }), + ), + ); + } } diff --git a/apps/forge/src/components/command-menu/index.tsx b/apps/forge/src/components/command-menu/index.tsx index 9a9caf9..af3292b 100644 --- a/apps/forge/src/components/command-menu/index.tsx +++ b/apps/forge/src/components/command-menu/index.tsx @@ -36,14 +36,14 @@ export default function CommandMenu() { const SETTINGS_SHORTCUTS: Record any, string, ReactElement]> = { p: [() => navigate({ to: "/user/profile" }), "Profile", ], - s: [() => navigate({ to: "/user/settings" }), "Settings", ], + ",": [() => navigate({ to: "/user/settings" }), "Settings", ], }; const USER_MANAGEMENT_SHORTCUTS: Record any, string, ReactElement]> = { d: [() => navigate({ to: "/signin/dashboard" }), "Dashboard", ], u: [() => navigate({ to: "/users" }), "Search users", ], - i: [() => navigate({ to: "/signin/actions" }), "Sign in", ], - o: [() => navigate({ to: "/signin/actions" }), "Sign out", ], + i: [() => navigate({ to: "/signin/actions/in" }), "Sign in", ], + o: [() => navigate({ to: "/signin/actions/out" }), "Sign out", ], }; const SHORTCUTS = { ...SETTINGS_SHORTCUTS, ...USER_MANAGEMENT_SHORTCUTS }; diff --git a/apps/forge/src/components/navbar/appNav/appLinkDropdown.tsx b/apps/forge/src/components/navbar/appNav/appLinkDropdown.tsx index 4144938..4d91e4e 100644 --- a/apps/forge/src/components/navbar/appNav/appLinkDropdown.tsx +++ b/apps/forge/src/components/navbar/appNav/appLinkDropdown.tsx @@ -16,7 +16,7 @@ const DropdownLink: React.FC = ({ link, onClick, activeId }) const isActive = linkItem.id === activeId; // Link styles - const linkClasses = `flex justify-center items-center p-2 text-sm font-medium rounded-md text-card-foreground hover:bg-accent/40 ${ + const linkClasses = `flex justify-center items-center p-2 text-sm font-medium rounded-md text-card-foreground hover:bg-accent ${ isActive ? "border-2 border-accent/40" : "" }`; diff --git a/apps/forge/src/components/signin/actions/QueueDispatcher.tsx b/apps/forge/src/components/signin/actions/QueueDispatcher.tsx index 571f027..30e7a82 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"; @@ -23,14 +23,15 @@ const QueueDispatcher: FlowStepComponent = ({ onSecondary, onPrimary }) => { const timeout = 3000; const queueProps: PostQueueProps = { + // FIXME locationName: activeLocation, uCardNumber: signInSession?.ucard_number ?? "", signal: abortController.signal, }; const { isPending, error, mutate } = useMutation({ - mutationKey: ["postQueueInPerson", queueProps], - mutationFn: () => PostQueueInPerson(queueProps), + mutationKey: ["postQueue", queueProps], + mutationFn: () => PostQueue(queueProps), retry: 0, onError: (error) => { console.log("Error", error); @@ -42,7 +43,7 @@ const QueueDispatcher: FlowStepComponent = ({ onSecondary, onPrimary }) => { abortController.abort(); dispatch(signinActions.resetSignInSession()); toast.success("User added to queue successfully"); - navigate({ to: "/signin/actions" }); + navigate({ to: "/signin" }); }, }); diff --git a/apps/forge/src/components/signin/actions/RegisterDispatcher.tsx b/apps/forge/src/components/signin/actions/RegisterDispatcher.tsx index d817d09..7b56738 100644 --- a/apps/forge/src/components/signin/actions/RegisterDispatcher.tsx +++ b/apps/forge/src/components/signin/actions/RegisterDispatcher.tsx @@ -48,7 +48,7 @@ const RegisterDispatcher: FlowStepComponent = ({ onSecondary, onPrimary }) => { abortController.abort(); dispatch(signinActions.resetSignInSession()); toast.success("User registered successfully"); - navigate({ to: "/signin/actions" }); + navigate({ to: "/signin" }); }, }); diff --git a/apps/forge/src/components/signin/actions/SignInDispatcher.tsx b/apps/forge/src/components/signin/actions/SignInDispatcher.tsx index afbe892..a934431 100644 --- a/apps/forge/src/components/signin/actions/SignInDispatcher.tsx +++ b/apps/forge/src/components/signin/actions/SignInDispatcher.tsx @@ -31,7 +31,7 @@ const SignInDispatcher: FlowStepComponent = ({ onSecondary, onPrimary }) => { ucard_number: signInSession?.ucard_number ?? "", location: activeLocation, reason_id: signInSession?.sign_in_reason?.id ?? "", - tools: signInSession?.training?.map((training) => training.name) ?? [], + tools: signInSession?.user?.training?.map((training) => training.name) ?? [], }, }; @@ -50,7 +50,7 @@ const SignInDispatcher: FlowStepComponent = ({ onSecondary, onPrimary }) => { dispatch(signinActions.resetSignInSession()); queryClient.invalidateQueries({ queryKey: ["locationStatus"] }); toast.success("User signed in!"); - navigate({ to: "/signin/actions" }); + navigate({ to: "/signin" }); }, }); diff --git a/apps/forge/src/components/signin/actions/SignInFlowProgress.tsx b/apps/forge/src/components/signin/actions/SignInFlowProgress.tsx index fa6944c..d7a58eb 100644 --- a/apps/forge/src/components/signin/actions/SignInFlowProgress.tsx +++ b/apps/forge/src/components/signin/actions/SignInFlowProgress.tsx @@ -37,9 +37,6 @@ const SignInFlowProgress: React.FC = ({ currentStep, flowTy case FlowType.SignOut: stepTitles = Object.values(SignOutSteps); break; - case FlowType.Register: - stepTitles = Object.values(RegisterSteps); - break; case FlowType.Enqueue: stepTitles = Object.values(EnqueueSteps); break; diff --git a/apps/forge/src/components/signin/actions/SignInManager.tsx b/apps/forge/src/components/signin/actions/SignInManager.tsx index cf1efa5..932043b 100644 --- a/apps/forge/src/components/signin/actions/SignInManager.tsx +++ b/apps/forge/src/components/signin/actions/SignInManager.tsx @@ -1,5 +1,4 @@ import QueueDispatcher from "@/components/signin/actions/QueueDispatcher.tsx"; -import RegisterDispatcher from "@/components/signin/actions/RegisterDispatcher.tsx"; import SignInDispatcher from "@/components/signin/actions/SignInDispatcher.tsx"; import SignInFlowProgress from "@/components/signin/actions/SignInFlowProgress.tsx"; import SignInReasonInput from "@/components/signin/actions/SignInReasonInput.tsx"; @@ -7,7 +6,7 @@ import SignOutDispatcher from "@/components/signin/actions/SignOutDispatcher.tsx import ToolSelectionInput from "@/components/signin/actions/ToolSelectionInput.tsx"; import UCardInput from "@/components/signin/actions/UCardInput.tsx"; import useDoubleTapEscape from "@/hooks/useDoubleTapEscape.ts"; -import { signinActions } from "@/redux/signin.slice.ts"; +import { signinActions, useSignInSessionField } from "@/redux/signin.slice.ts"; import { AppDispatch, AppRootState } from "@/redux/store.ts"; import { AnyStep, @@ -15,15 +14,16 @@ import { FlowConfiguration, FlowStepComponent, FlowType, - RegisterSteps, SignInSteps, SignOutSteps, flowTypeToPrintTable, } from "@/types/signInActions.ts"; import { SignInSession } from "@/types/signin.ts"; +import { useNavigate } from "@tanstack/react-router"; import { Button } from "@ui/components/ui/button.tsx"; import React, { ReactElement, useEffect, useLayoutEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; +import SigningInUserCard from "./SigningInUserCard"; const flowConfig: FlowConfiguration = { [FlowType.SignIn]: { @@ -36,10 +36,6 @@ const flowConfig: FlowConfiguration = { [SignOutSteps.Step1]: UCardInput, [SignOutSteps.Step2]: SignOutDispatcher, }, - [FlowType.Register]: { - [RegisterSteps.Step1]: UCardInput, - [RegisterSteps.Step2]: RegisterDispatcher, - }, [FlowType.Enqueue]: { [EnqueueSteps.Step1]: UCardInput, [EnqueueSteps.Step2]: QueueDispatcher, @@ -48,16 +44,16 @@ const flowConfig: FlowConfiguration = { const defaultSignInSession: SignInSession = { ucard_number: "", - is_rep: false, - sign_in_reason: null, + user: null, training: null, - navigation_is_backtracking: false, + sign_in_reason: null, session_errored: false, - username: null, + navigation_is_backtracking: false, }; -interface SignInManagerProps { - initialFlow?: FlowType; +interface SignInManagerProps { + initialFlow?: FlowT; + initialStep?: FlowT extends FlowType ? keyof FlowConfiguration[FlowT] : undefined; } export const getStepComponent = ( @@ -70,8 +66,6 @@ export const getStepComponent = ( return flowConfig[currentFlow][currentStep as SignInSteps]; case FlowType.SignOut: return flowConfig[currentFlow][currentStep as SignOutSteps]; - case FlowType.Register: - return flowConfig[currentFlow][currentStep as RegisterSteps]; case FlowType.Enqueue: return flowConfig[currentFlow][currentStep as EnqueueSteps]; default: @@ -80,12 +74,17 @@ export const getStepComponent = ( }; // SignInActionsManager Component -const SignInActionsManager: React.FC = ({ initialFlow }) => { +export default function SignInActionsManager({ + initialFlow, + initialStep, +}: SignInManagerProps): React.ReactElement { const [currentFlow, setCurrentFlow] = useState(null); const [currentStep, setCurrentStep] = useState(null); const activeLocation = useSelector((state: AppRootState) => state.signin.active_location); + const user = useSignInSessionField("user"); const dispatch = useDispatch(); + const navigate = useNavigate(); const handleDoubleTapEscape = () => { setCurrentFlow(null); @@ -132,7 +131,7 @@ const SignInActionsManager: React.FC = ({ initialFlow }) => // Make new Session useEffect(() => { - dispatch(signinActions.setSignInSession(defaultSignInSession)); + if (!initialStep) dispatch(signinActions.setSignInSession(defaultSignInSession)); }, []); useEffect(() => { @@ -144,19 +143,23 @@ const SignInActionsManager: React.FC = ({ initialFlow }) => useLayoutEffect(() => { if (initialFlow) { setCurrentFlow(initialFlow); - // Dynamically set the initial step for the initialFlow - const initialStep = Object.keys(flowConfig[initialFlow])[0] as AnyStep; - setCurrentStep(initialStep); + if (!initialStep) { + // Dynamically set the initial step for the initialFlow + initialStep = Object.keys(flowConfig[initialFlow])[0] as any; + } + setCurrentStep(initialStep!); } - }, [initialFlow]); + }, []); // Function to initialize the flow const startFlow = (flowType: FlowType) => { setCurrentFlow(flowType); - // Dynamically set the initial step based on the flowType - const initialStep = Object.keys(flowConfig[flowType])[0] as AnyStep; - setCurrentStep(initialStep); - dispatch(signinActions.setSignInSession(defaultSignInSession)); + if (!initialStep) { + // Dynamically set the initial step based on the flowType + const initialStep: AnyStep = Object.keys(flowConfig[flowType])[0] as AnyStep; + setCurrentStep(initialStep); + dispatch(signinActions.setSignInSession(defaultSignInSession)); + } }; const renderCurrentStep = (): ReactElement | null => { @@ -182,16 +185,24 @@ const SignInActionsManager: React.FC = ({ initialFlow }) => const totalSteps = currentFlow ? getTotalSteps(currentFlow) : 0; const currentStepIndex = currentStep ? getStepIndex(Object.values(SignInSteps), currentStep) : 0; + const buttonStyles = "h-20 w-64"; + return ( -
-

Sign In Actions

+
{currentFlow && (
Current Flow: {flowTypeToPrintTable(currentFlow)}
- +
)} @@ -202,24 +213,24 @@ const SignInActionsManager: React.FC = ({ initialFlow }) => {/* Pass the current step's index and total steps */}
{`Current Step: ${currentStepIndex + 1} of ${totalSteps}`}
-
{renderCurrentStep()}
+
+ {user && } +
{renderCurrentStep()}
+
)} {!currentFlow && (
-
-
- - - -
@@ -229,6 +240,4 @@ const SignInActionsManager: React.FC = ({ initialFlow }) =>
); -}; - -export default SignInActionsManager; +} diff --git a/apps/forge/src/components/signin/actions/SignOutDispatcher.tsx b/apps/forge/src/components/signin/actions/SignOutDispatcher.tsx index d9df804..281afb9 100644 --- a/apps/forge/src/components/signin/actions/SignOutDispatcher.tsx +++ b/apps/forge/src/components/signin/actions/SignOutDispatcher.tsx @@ -43,7 +43,7 @@ const SignOutDispatcher: FlowStepComponent = ({ onSecondary, onPrimary }) => { dispatch(signinActions.resetSignInSession()); queryClient.invalidateQueries({ queryKey: ["locationStatus"] }); toast.success("User signed out successfully!"); - navigate({ to: "/signin/actions" }); + navigate({ to: "/signin" }); }, }); diff --git a/apps/forge/src/components/signin/actions/SigningInUserCard.tsx b/apps/forge/src/components/signin/actions/SigningInUserCard.tsx new file mode 100644 index 0000000..b2414a1 --- /dev/null +++ b/apps/forge/src/components/signin/actions/SigningInUserCard.tsx @@ -0,0 +1,37 @@ +import { UserAvatar } from "@/components/avatar"; +import { TeamIcon } from "@/components/icons/Team"; +import { sign_in } from "@ignis/types"; +import { Link } from "@tanstack/react-router"; +import { Badge } from "@ui/components/ui/badge"; +import { Card } from "@ui/components/ui/card"; + +export default function SigningInUserCard({ user }: { user: sign_in.User }) { + return ( + +
+
+
+ +

{user.display_name}

+ +
+ {user.teams?.map((team) => ( + +
+ +

{team.name}

+
+
+ ))} +
+
+ +
+
+
+ ); +} diff --git a/apps/forge/src/components/signin/actions/ToolSelectionInput.tsx b/apps/forge/src/components/signin/actions/ToolSelectionInput.tsx index 9230480..e2a1a5e 100644 --- a/apps/forge/src/components/signin/actions/ToolSelectionInput.tsx +++ b/apps/forge/src/components/signin/actions/ToolSelectionInput.tsx @@ -31,7 +31,8 @@ const ToolSelectionInput: FlowStepComponent = ({ onSecondary, onPrimary }) => { const abortController = new AbortController(); // For gracefully cancelling the query const activeLocation = useSelector((state: AppRootState) => state.signin.active_location); - const ucardNumber = useSignInSessionField("ucard_number"); + const uCardNumber = useSignInSessionField("ucard_number"); + const user = useSignInSessionField("user"); const [isOpen, setIsOpen] = useState(false); const [trainingMap, setTrainingMap] = useState({ @@ -51,14 +52,19 @@ const ToolSelectionInput: FlowStepComponent = ({ onSecondary, onPrimary }) => { const signInProps: GetSignInProps = { locationName: activeLocation, - uCardNumber: ucardNumber ?? "", + uCardNumber: uCardNumber ?? "", signal: abortController.signal, }; - // Using the useQuery hook to fetch the sign-in data const { data, isLoading, error } = useQuery({ queryKey: ["getSignIn", signInProps], - queryFn: () => GetSignIn(signInProps), + queryFn: () => { + if (user) { + return user; + } + return GetSignIn(signInProps); + }, + retry: 1, }); const handleOnTrainingSelect = (selectedTrainings: Training[]) => { @@ -105,6 +111,7 @@ const ToolSelectionInput: FlowStepComponent = ({ onSecondary, onPrimary }) => { } } } + dispatch(signinActions.updateSignInSessionField("user", data)); setTrainingMap({ SELECTABLE: selectAbleTraining, UNSELECTABLE: unselectAbleTraining, diff --git a/apps/forge/src/components/signin/actions/UCardInput.tsx b/apps/forge/src/components/signin/actions/UCardInput.tsx index 0aa3ecc..4a47845 100644 --- a/apps/forge/src/components/signin/actions/UCardInput.tsx +++ b/apps/forge/src/components/signin/actions/UCardInput.tsx @@ -1,17 +1,16 @@ import { UCARD_LENGTH } from "@/lib/constants"; -import { ucardNumberToString } from "@/lib/utils"; import { signinActions, useSignInSessionField } from "@/redux/signin.slice.ts"; import { AppDispatch } from "@/redux/store.ts"; import { FlowStepComponent } from "@/types/signInActions.ts"; import { Button } from "@ui/components/ui/button.tsx"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@ui/components/ui/card.tsx"; import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@ui/components/ui/input-otp.tsx"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { useDispatch } from "react-redux"; const UCardInput: FlowStepComponent = ({ onPrimary }) => { const dispatch = useDispatch(); - const [otp, setOtp] = useState(useSignInSessionField("ucard_number")); // OTP is now handled as a string + const [otp, setOtp] = useState(useSignInSessionField("ucard_number")!); // OTP is now handled as a string const [isOtpValid, setIsOtpValid] = useState(otp.length === UCARD_LENGTH); const handleOtpChange = (value: string) => { @@ -22,6 +21,7 @@ const UCardInput: FlowStepComponent = ({ onPrimary }) => { const handleClear = () => { console.log("Clearing OTP"); setOtp(""); // Clear the OTP by resetting the state + dispatch(signinActions.updateSignInSessionField("ucard_number", "")); }; const handleOnSubmit = () => { @@ -30,11 +30,6 @@ const UCardInput: FlowStepComponent = ({ onPrimary }) => { onPrimary?.(); } }; - const firstInputRef = useRef(null); - - useEffect(() => { - firstInputRef.current?.focus(); - }, []); return ( <> @@ -51,7 +46,6 @@ const UCardInput: FlowStepComponent = ({ onPrimary }) => { onChange={(value) => handleOtpChange(value)} onComplete={() => handleOnSubmit()} pushPasswordManagerStrategy="none" - ref={firstInputRef} data-lpignore="true" data-1p-ignore="true" > diff --git a/apps/forge/src/components/signin/dashboard/components/SignInsChart.tsx b/apps/forge/src/components/signin/dashboard/components/SignInsChart.tsx index cc00e39..49243aa 100644 --- a/apps/forge/src/components/signin/dashboard/components/SignInsChart.tsx +++ b/apps/forge/src/components/signin/dashboard/components/SignInsChart.tsx @@ -1,7 +1,7 @@ import { LocationIcon } from "@/components/icons/Locations"; import { SignInStat } from "@ignis/types/users"; import { Datum, ResponsiveCalendar } from "@nivo/calendar"; -import { useNavigate } from "@tanstack/react-router"; +import { Link, useNavigate } from "@tanstack/react-router"; import { Button } from "@ui/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@ui/components/ui/dialog"; import { Drawer, DrawerClose, DrawerContent, DrawerFooter, DrawerHeader, DrawerTitle } from "@ui/components/ui/drawer"; @@ -12,8 +12,6 @@ import MediaQuery from "react-responsive"; type SignInDatum = Omit & { data: Datum["data"] & SignInStat }; function SignInTable({ datum }: { datum: SignInDatum | null }) { - const navigate = useNavigate(); - return ( @@ -32,19 +30,20 @@ function SignInTable({ datum }: { datum: SignInDatum | null }) { const minutes_string = `${minutes} minute${minutes !== 1 ? "s" : ""}`; return ( - navigate({ to: `/sign-ins/${sign_in.id}` as string })} - > - - - - {sign_in.created_at.toLocaleTimeString()} - {sign_in.ends_at?.toLocaleTimeString() || "-"} - - {hours && minutes ? `${hours_string} and ${minutes_string}` : hours ? hours_string : minutes_string} - - + <> + + + + + + {sign_in.created_at.toLocaleTimeString()} + {sign_in.ends_at?.toLocaleTimeString() || "-"} + + {hours && minutes ? `${hours_string} and ${minutes_string}` : hours ? hours_string : minutes_string} + + + + ); })} 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 1b4c238..9133b77 100644 --- a/apps/forge/src/components/signin/dashboard/components/SignedInUserCard/index.tsx +++ b/apps/forge/src/components/signin/dashboard/components/SignedInUserCard/index.tsx @@ -6,7 +6,7 @@ import { SignInReasonDisplay } from "@/components/signin/dashboard/components/Si import { TimeDisplay } from "@/components/signin/dashboard/components/SignedInUserCard/TimeDisplay.tsx"; import { iForgeEpoch } from "@/config/constants.ts"; import { REP_OFF_SHIFT, REP_ON_SHIFT } from "@/lib/constants.ts"; -import { ucardNumberToString } from "@/lib/utils"; +import { uCardNumberToString } from "@/lib/utils"; import { AppRootState } from "@/redux/store.ts"; import { PostSignOut, PostSignOutProps } from "@/services/signin/signInService.ts"; import type { PartialReason } from "@ignis/types/sign_in.ts"; @@ -48,7 +48,7 @@ export const SignedInUserCard: React.FunctionComponent = ({ const signOutProps: PostSignOutProps = { locationName: activeLocation, - uCardNumber: ucardNumberToString(user.ucard_number), + uCardNumber: uCardNumberToString(user.ucard_number), signal: abortController.signal, }; diff --git a/apps/forge/src/components/signin/dashboard/components/TeamIcon.tsx b/apps/forge/src/components/signin/dashboard/components/TeamIcon.tsx deleted file mode 100644 index 87e54a1..0000000 --- a/apps/forge/src/components/signin/dashboard/components/TeamIcon.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { removeSuffix } from "@/lib/utils"; -import { - BarChart4, - Box, - CalendarIcon, - Cog, - Computer, - Construction, - Diamond, - Hammer, - HardHat, - LucideIcon, - Megaphone, - Puzzle, - Send, - Users, -} from "lucide-react"; -import * as React from "react"; - -export default function TeamIcon({ team, ...props }: { team: string } & React.ComponentProps) { - switch (removeSuffix(team, "Team").trim()) { - case "IT": - return ; - case "3DP": - return ; - case "Hardware": - return ; - case "Publicity": - return ; - case "Events": - return ; - case "Relations": - return ; - case "Operations": - return ; - case "Recruitment & Development": - return ; // stonks? - case "Health & Safety": - return ; - case "Inclusions": - return ; - case "Unsorted Reps": - return ; - case "Future Reps": - return ; - case "Staff": - return ; - default: - throw new Error("Sorry your team has been forgotten about... cry harder"); - } -} diff --git a/apps/forge/src/components/training/TrainingCourseCard.tsx b/apps/forge/src/components/training/TrainingCourseCard.tsx index 6805db2..f8ea93a 100644 --- a/apps/forge/src/components/training/TrainingCourseCard.tsx +++ b/apps/forge/src/components/training/TrainingCourseCard.tsx @@ -1,9 +1,8 @@ import { TrainingContent } from "@/routes/_authenticated/training/$id"; import { PartialTrainingWithStatus } from "@ignis/types/training"; -import { useNavigate } from "@tanstack/react-router"; +import { Link } from "@tanstack/react-router"; import { Button } from "@ui/components/ui/button"; import { Card } from "@ui/components/ui/card"; -import Markdown from "react-markdown"; export default function TrainingCourseCard({ training, @@ -12,7 +11,6 @@ export default function TrainingCourseCard({ training: PartialTrainingWithStatus; isRep: boolean; }) { - const navigate = useNavigate(); return (
@@ -22,19 +20,15 @@ export default function TrainingCourseCard({
- + + + {isRep && training.rep !== null ? ( - + + + ) : undefined}
diff --git a/apps/forge/src/components/ucard-reader/index.tsx b/apps/forge/src/components/ucard-reader/index.tsx new file mode 100644 index 0000000..bc29497 --- /dev/null +++ b/apps/forge/src/components/ucard-reader/index.tsx @@ -0,0 +1,85 @@ +import { signinActions } from "@/redux/signin.slice"; +import { AppDispatch, AppRootState } from "@/redux/store"; +import { GetSignIn, PostSignOut } from "@/services/signin/signInService"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { toast } from "sonner"; + +export default function UCardReader() { + const [keysPressed, setKeysPressed] = useState<{ key: string; timestamp: number }[]>([]); + const activeLocation = useSelector((state: AppRootState) => state.signin.active_location); + const dispatch: AppDispatch = useDispatch(); + const navigate = useNavigate(); + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement) { + // TODO capture the input if a ucard is detected. This should be possible assuming the card reader is fast af + // (more than 50ms for full entry though that's assuming display input is instant if we factor that in it should be about 0.0333s) + // Don't capture key presses when the target is an input field + return; + } + setKeysPressed((prevKeys) => [...prevKeys, { key: e.key, timestamp: Date.now() }].slice(-10)); + }; + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, []); + + useEffect(() => { + (async () => { + const uCardNumber = keysPressed + .slice(0, -1) // Exclude the last key press + .map(({ key }) => key) + .join("") + .match(/\d{9}$/)?.[0]; + if ( + uCardNumber && + keysPressed[keysPressed.length - 1].key === "Enter" && // Last key press is "Enter" + keysPressed[keysPressed.length - 1].timestamp - keysPressed[0].timestamp < 10_000 // All entered in 10ms + ) { + const userProps = { uCardNumber, locationName: activeLocation, signal: undefined as any }; + const matchingUser = await GetSignIn(userProps); + + if (matchingUser.signed_in) { + toast(`User ${uCardNumber} would like to sign out`, { + description: "Sign out this user?", + action: { + label: "Yes", + onClick: () => { + (async () => { + try { + await PostSignOut(userProps); + } catch (e) { + toast.error(`Failed to sign out user ${uCardNumber}`, { description: (e as any).toString() }); + } + })(); + }, + }, + }); + } else { + dispatch( + signinActions.setSignInSession({ + ucard_number: uCardNumber, + user: matchingUser, + training: null, + sign_in_reason: null, + session_errored: false, + navigation_is_backtracking: false, + }), + ); + toast(`User ${uCardNumber} would like to sign in`, { + description: "Sign in this user?", + action: { + label: "Go to sign in", + onClick: () => { + navigate({ to: "/signin/actions/in-faster" }); + }, + }, + }); + } + } + })(); + }, [keysPressed, activeLocation]); + return undefined; +} diff --git a/apps/forge/src/config/appLinks.ts b/apps/forge/src/config/appLinks.ts index 4d696c2..77ef9ed 100644 --- a/apps/forge/src/config/appLinks.ts +++ b/apps/forge/src/config/appLinks.ts @@ -12,7 +12,7 @@ export type AppLink = { export const appLinks: AppLink[] = [ { app: "Main", displayName: "Home", path: "/", index: 0, id: "home" }, - { app: "Sign In", displayName: "Sign In", path: "/signin", index: 0, id: "signin_root" }, + { app: "Sign In", displayName: "Home", path: "/signin", index: 0, id: "signin_root" }, { app: "Sign In", displayName: "Agreements", @@ -52,7 +52,6 @@ export const appLinks: AppLink[] = [ ], }, { app: "Sign In", displayName: "Dashboard", path: "/signin/dashboard", index: 3, id: "signin_dashboard" }, - { app: "Sign In", displayName: "Home", path: "/signin/home", index: 4, id: "signin_home" }, { app: "Training", displayName: "Home", path: "/training", index: 0, id: "training_home" }, { app: "Training", diff --git a/apps/forge/src/lib/utils.ts b/apps/forge/src/lib/utils.ts index b230f7b..5b59c8b 100644 --- a/apps/forge/src/lib/utils.ts +++ b/apps/forge/src/lib/utils.ts @@ -49,6 +49,6 @@ export function extractError(error: Error): string { return error?.message || "Unknown Error. Contact the IT Team"; } -export function ucardNumberToString(ucard_number: number): string { +export function uCardNumberToString(ucard_number: number): string { return ucard_number.toString().padStart(UCARD_LENGTH, "0"); } diff --git a/apps/forge/src/main.tsx b/apps/forge/src/main.tsx index 8ed8691..96cd0da 100644 --- a/apps/forge/src/main.tsx +++ b/apps/forge/src/main.tsx @@ -1,20 +1,20 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; import "@/index.css"; +import { ThemeProvider } from "@/providers/themeProvider"; +import { persistor, store } from "@/redux/store"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { ThemeProvider } from "@/providers/themeProvider"; -import { createRouter, RouterProvider } from "@tanstack/react-router"; +import { RouterProvider, createRouter } from "@tanstack/react-router"; +import React from "react"; +import ReactDOM from "react-dom/client"; import { HelmetProvider } from "react-helmet-async"; import { Provider } from "react-redux"; -import { persistor, store } from "@/redux/store"; -import { PersistGate } from "redux-persist/integration/react"; +import { AuthProvider, useAuth } from "@/components/auth-provider"; +import { Loading } from "@/components/routing/Loading.tsx"; import { routeTree } from "@/routeTree.gen.ts"; import { Toaster } from "@ui/components/ui/sonner.tsx"; -import { AuthProvider, useAuth } from "@/components/auth-provider"; import posthog from "posthog-js"; -import { Loading } from "@/components/routing/Loading.tsx"; +import { PersistGate } from "redux-persist/integration/react"; // Begin Router const queryClient = new QueryClient(); diff --git a/apps/forge/src/routes/__root.tsx b/apps/forge/src/routes/__root.tsx index b18ba59..829ab9b 100644 --- a/apps/forge/src/routes/__root.tsx +++ b/apps/forge/src/routes/__root.tsx @@ -1,13 +1,14 @@ -import { createRootRouteWithContext, Outlet, ScrollRestoration } from "@tanstack/react-router"; -import { QueryClient } from "@tanstack/react-query"; -import NavBar from "@/components/navbar"; -import CommandMenu from "@/components/command-menu"; -import React, { Suspense } from "react"; import { AuthContext } from "@/components/auth-provider"; -import { NotFound } from "@/components/routing/NotFound.tsx"; +import CommandMenu from "@/components/command-menu"; +import { TailwindIndicator } from "@/components/dev/Tailwind-Indicator.tsx"; +import NavBar from "@/components/navbar"; import { GenericError } from "@/components/routing/GenericError.tsx"; import { Loading } from "@/components/routing/Loading.tsx"; -import { TailwindIndicator } from "@/components/dev/Tailwind-Indicator.tsx"; +import { NotFound } from "@/components/routing/NotFound.tsx"; +import UCardReader from "@/components/ucard-reader"; +import { QueryClient } from "@tanstack/react-query"; +import { Outlet, ScrollRestoration, createRootRouteWithContext } from "@tanstack/react-router"; +import React, { Suspense } from "react"; const TanStackRouterDevtools = process.env.NODE_ENV === "production" @@ -26,6 +27,7 @@ function RootComponent() { + {/* This is where child routes will render */} diff --git a/apps/forge/src/routes/_authenticated/_reponly/signin/actions/in-faster.tsx b/apps/forge/src/routes/_authenticated/_reponly/signin/actions/in-faster.tsx new file mode 100644 index 0000000..19e133c --- /dev/null +++ b/apps/forge/src/routes/_authenticated/_reponly/signin/actions/in-faster.tsx @@ -0,0 +1,21 @@ +import ActiveLocationSelector from "@/components/signin/ActiveLocationSelector"; +import SignInActionsManager from "@/components/signin/actions/SignInManager"; +import Title from "@/components/title"; +import { FlowType, SignInSteps } from "@/types/signInActions"; +import { createFileRoute } from "@tanstack/react-router"; + +const OutComponent = () => { + return ( + <> +
+ + <ActiveLocationSelector /> + <SignInActionsManager initialFlow={FlowType.SignIn} initialStep={SignInSteps.Step2} /> + </div> + </> + ); +}; + +export const Route = createFileRoute("/_authenticated/_reponly/signin/actions/in-faster")({ + component: OutComponent, +}); diff --git a/apps/forge/src/routes/_authenticated/_reponly/signin/actions/index.tsx b/apps/forge/src/routes/_authenticated/_reponly/signin/actions/index.tsx deleted file mode 100644 index e87d333..0000000 --- a/apps/forge/src/routes/_authenticated/_reponly/signin/actions/index.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; - -export const Route = createFileRoute("/_authenticated/_reponly/signin/actions/")({ - beforeLoad: () => { - throw redirect({ - to: "/signin/", - }); - }, -}); diff --git a/apps/forge/src/routes/_authenticated/signin/agreements/index.tsx b/apps/forge/src/routes/_authenticated/signin/agreements/index.tsx index a4d92f1..ebee551 100644 --- a/apps/forge/src/routes/_authenticated/signin/agreements/index.tsx +++ b/apps/forge/src/routes/_authenticated/signin/agreements/index.tsx @@ -2,7 +2,7 @@ import { useUser } from "@/lib/utils"; import { getAgreements } from "@/services/root/getAgreements"; import { Agreement } from "@ignis/types/root"; import { useQuery } from "@tanstack/react-query"; -import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { Link, createFileRoute, useNavigate } from "@tanstack/react-router"; import { Badge } from "@ui/components/ui/badge"; import { Loader } from "@ui/components/ui/loader"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ui/components/ui/table"; @@ -56,22 +56,20 @@ export default function Component() { </TableHeader> <TableBody> {agreements.map((agreement) => ( - <TableRow - key={agreement.id} - className="hover:bg-accent hover:cursor-pointer" - onClick={() => navigate({ to: "/signin/agreements/$id", params: agreement })} - > - <TableCell>{agreement.reasons.map((reason) => reason.name).join(", ")}</TableCell> - <TableCell> - <div className="flex justify-center"> - <Badge variant="outline" className="rounded-md"> - {getAgreementStatus(agreement)} - </Badge> - </div> - </TableCell> - <TableCell className="text-center">{agreement.version}</TableCell> - <TableCell>{new Date(agreement.created_at).toLocaleDateString()}</TableCell> - </TableRow> + <Link to="/signin/agreements/$id" params={agreement}> + <TableRow key={agreement.id} className="hover:bg-accent hover:cursor-pointer"> + <TableCell>{agreement.reasons.map((reason) => reason.name).join(", ")}</TableCell> + <TableCell> + <div className="flex justify-center"> + <Badge variant="outline" className="rounded-md"> + {getAgreementStatus(agreement)} + </Badge> + </div> + </TableCell> + <TableCell className="text-center">{agreement.version}</TableCell> + <TableCell>{new Date(agreement.created_at).toLocaleDateString()}</TableCell> + </TableRow> + </Link> ))} </TableBody> </Table> diff --git a/apps/forge/src/routes/_authenticated/signin/home/index.tsx b/apps/forge/src/routes/_authenticated/signin/home/index.tsx deleted file mode 100644 index b55690f..0000000 --- a/apps/forge/src/routes/_authenticated/signin/home/index.tsx +++ /dev/null @@ -1,472 +0,0 @@ -import ActiveLocationSelector from "@/components/signin/ActiveLocationSelector"; -import Title from "@/components/title"; -import { useUser } from "@/lib/utils.ts"; -import { createFileRoute } from "@tanstack/react-router"; -import { Book, Coins, MessageCircleMore } from "lucide-react"; -import { World } from "@ui/components/globe.tsx"; -import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@ui/components/ui/card.tsx"; -import { Button } from "@ui/components/ui/button.tsx"; - -const globeConfig = { - pointSize: 4, - globeColor: "#062056", - showAtmosphere: true, - atmosphereColor: "#FFFFFF", - atmosphereAltitude: 0.1, - emissive: "#062056", - emissiveIntensity: 0.1, - shininess: 0.9, - polygonColor: "rgba(255,255,255,0.7)", - ambientLight: "#38bdf8", - directionalLeftLight: "#ffffff", - directionalTopLight: "#ffffff", - pointLight: "#ffffff", - arcTime: 1000, - arcLength: 0.9, - rings: 1, - maxRings: 3, - initialPosition: { lat: 22.3193, lng: 114.1694 }, - autoRotate: true, - autoRotateSpeed: 0.5, -}; -const colors = ["#06b6d4", "#3b82f6", "#6366f1"]; -const sampleArcs = [ - { - order: 1, - startLat: -19.885592, - startLng: -43.951191, - endLat: -22.9068, - endLng: -43.1729, - arcAlt: 0.1, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 1, - startLat: 28.6139, - startLng: 77.209, - endLat: 3.139, - endLng: 101.6869, - arcAlt: 0.2, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 1, - startLat: -19.885592, - startLng: -43.951191, - endLat: -1.303396, - endLng: 36.852443, - arcAlt: 0.5, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 2, - startLat: 1.3521, - startLng: 103.8198, - endLat: 35.6762, - endLng: 139.6503, - arcAlt: 0.2, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 2, - startLat: 51.5072, - startLng: -0.1276, - endLat: 3.139, - endLng: 101.6869, - arcAlt: 0.3, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 2, - startLat: -15.785493, - startLng: -47.909029, - endLat: 36.162809, - endLng: -115.119411, - arcAlt: 0.3, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 3, - startLat: -33.8688, - startLng: 151.2093, - endLat: 22.3193, - endLng: 114.1694, - arcAlt: 0.3, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 3, - startLat: 21.3099, - startLng: -157.8581, - endLat: 40.7128, - endLng: -74.006, - arcAlt: 0.3, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 3, - startLat: -6.2088, - startLng: 106.8456, - endLat: 51.5072, - endLng: -0.1276, - arcAlt: 0.3, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 4, - startLat: 11.986597, - startLng: 8.571831, - endLat: -15.595412, - endLng: -56.05918, - arcAlt: 0.5, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 4, - startLat: -34.6037, - startLng: -58.3816, - endLat: 22.3193, - endLng: 114.1694, - arcAlt: 0.7, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 4, - startLat: 51.5072, - startLng: -0.1276, - endLat: 48.8566, - endLng: -2.3522, - arcAlt: 0.1, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 5, - startLat: 14.5995, - startLng: 120.9842, - endLat: 51.5072, - endLng: -0.1276, - arcAlt: 0.3, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 5, - startLat: 1.3521, - startLng: 103.8198, - endLat: -33.8688, - endLng: 151.2093, - arcAlt: 0.2, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 5, - startLat: 34.0522, - startLng: -118.2437, - endLat: 48.8566, - endLng: -2.3522, - arcAlt: 0.2, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 6, - startLat: -15.432563, - startLng: 28.315853, - endLat: 1.094136, - endLng: -63.34546, - arcAlt: 0.7, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 6, - startLat: 37.5665, - startLng: 126.978, - endLat: 35.6762, - endLng: 139.6503, - arcAlt: 0.1, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 6, - startLat: 22.3193, - startLng: 114.1694, - endLat: 51.5072, - endLng: -0.1276, - arcAlt: 0.3, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 7, - startLat: -19.885592, - startLng: -43.951191, - endLat: -15.595412, - endLng: -56.05918, - arcAlt: 0.1, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 7, - startLat: 48.8566, - startLng: -2.3522, - endLat: 52.52, - endLng: 13.405, - arcAlt: 0.1, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 7, - startLat: 52.52, - startLng: 13.405, - endLat: 34.0522, - endLng: -118.2437, - arcAlt: 0.2, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 8, - startLat: -8.833221, - startLng: 13.264837, - endLat: -33.936138, - endLng: 18.436529, - arcAlt: 0.2, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 8, - startLat: 49.2827, - startLng: -123.1207, - endLat: 52.3676, - endLng: 4.9041, - arcAlt: 0.2, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 8, - startLat: 1.3521, - startLng: 103.8198, - endLat: 40.7128, - endLng: -74.006, - arcAlt: 0.5, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 9, - startLat: 51.5072, - startLng: -0.1276, - endLat: 34.0522, - endLng: -118.2437, - arcAlt: 0.2, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 9, - startLat: 22.3193, - startLng: 114.1694, - endLat: -22.9068, - endLng: -43.1729, - arcAlt: 0.7, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 9, - startLat: 1.3521, - startLng: 103.8198, - endLat: -34.6037, - endLng: -58.3816, - arcAlt: 0.5, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 10, - startLat: -22.9068, - startLng: -43.1729, - endLat: 28.6139, - endLng: 77.209, - arcAlt: 0.7, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 10, - startLat: 34.0522, - startLng: -118.2437, - endLat: 31.2304, - endLng: 121.4737, - arcAlt: 0.3, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 10, - startLat: -6.2088, - startLng: 106.8456, - endLat: 52.3676, - endLng: 4.9041, - arcAlt: 0.3, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 11, - startLat: 41.9028, - startLng: 12.4964, - endLat: 34.0522, - endLng: -118.2437, - arcAlt: 0.2, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 11, - startLat: -6.2088, - startLng: 106.8456, - endLat: 31.2304, - endLng: 121.4737, - arcAlt: 0.2, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 11, - startLat: 22.3193, - startLng: 114.1694, - endLat: 1.3521, - endLng: 103.8198, - arcAlt: 0.2, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 12, - startLat: 34.0522, - startLng: -118.2437, - endLat: 37.7749, - endLng: -122.4194, - arcAlt: 0.1, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 12, - startLat: 35.6762, - startLng: 139.6503, - endLat: 22.3193, - endLng: 114.1694, - arcAlt: 0.2, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 12, - startLat: 22.3193, - startLng: 114.1694, - endLat: 34.0522, - endLng: -118.2437, - arcAlt: 0.3, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 13, - startLat: 52.52, - startLng: 13.405, - endLat: 22.3193, - endLng: 114.1694, - arcAlt: 0.3, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 13, - startLat: 11.986597, - startLng: 8.571831, - endLat: 35.6762, - endLng: 139.6503, - arcAlt: 0.3, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 13, - startLat: -22.9068, - startLng: -43.1729, - endLat: -34.6037, - endLng: -58.3816, - arcAlt: 0.1, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, - { - order: 14, - startLat: -33.936138, - startLng: 18.436529, - endLat: 21.395643, - endLng: 39.883798, - arcAlt: 0.3, - color: colors[Math.floor(Math.random() * (colors.length - 1))], - }, -]; - -const SignInAppComponent = () => { - const items = [ - { - title: "iForge Discord", - description: "Join the iForge Discord if you haven't already.", - icon: <MessageCircleMore className="h-8 w-8 text-neutral-500" />, - linkText: "Join", - externalLink: import.meta.env.VITE_DISCORD_URL, - }, - ]; - const user = useUser(); - - if (user?.roles.some((role) => role.name.includes("Rep"))) { - items.push( - { - title: "Purchase Form", - description: "Add Items that need to be purchased to the list via the purchase form!", - icon: <Coins className="h-8 w-8 text-neutral-500" />, - linkText: "Buy", - externalLink: - "https://docs.google.com/forms/d/e/1FAIpQLScdLTE7eXqGQRa3e0UfymYo8qjlNTyu5xfIyArMG0wGQgHjyw/viewform", - }, - { - title: "iDocs", - description: "Check out your team specific documentation here (coming soon)", - icon: <Book className="h-8 w-8 text-neutral-500" />, - linkText: "Learn", - externalLink: "https://docs.iforge.shef.ac.uk", - }, - ); - } - - return ( - <> - <Title prompt="Signin App Home" /> - <div className="p-4 mt-1"> - <ActiveLocationSelector /> - <div className="border-2 rounded-md p-4"> - <h1 className="text-xl font-bold mb-4 text-center">Sign in Home</h1> - <div className="flex flex-col-reverse md:flex-row"> - <div className="md:w-1/2 flex justify-center items-center p-4"> - <div className="aspect-square relative w-full h-full"> - <World data={sampleArcs} globeConfig={globeConfig} /> - </div> - </div> - <div className="md:w-1/2 flex justify-center items-center"> - <div className="grid grid-cols-1 gap-4 mx-auto"> - {" "} - {/* Use mx-auto for horizontal centering */} - {items.map((item) => ( - <Card key={item.title} className="shadow-md rounded-md p-4 max-w-md"> - <CardHeader> - <CardTitle className="flex items-center"> - {" "} - {/* Use items-center for vertical alignment */} - {item.icon} - <span className="ml-2">{item.title}</span> - </CardTitle> - </CardHeader> - <CardContent className="text-balance">{item.description}</CardContent> - <CardFooter> - <a href={item.externalLink} target="_blank" rel="noopener noreferrer"> - <Button>{item.title}</Button> - </a> - </CardFooter> - </Card> - ))} - </div> - </div> - </div> - </div> - </div> - </> - ); -}; - -export const Route = createFileRoute("/_authenticated/signin/home/")({ component: SignInAppComponent }); diff --git a/apps/forge/src/routes/_authenticated/signin/index.tsx b/apps/forge/src/routes/_authenticated/signin/index.tsx index 5d69269..f06488b 100644 --- a/apps/forge/src/routes/_authenticated/signin/index.tsx +++ b/apps/forge/src/routes/_authenticated/signin/index.tsx @@ -1,27 +1,484 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; import ActiveLocationSelector from "@/components/signin/ActiveLocationSelector"; import SignInActionsManager from "@/components/signin/actions/SignInManager.tsx"; import Title from "@/components/title"; +import { useUser } from "@/lib/utils.ts"; +import { createFileRoute } from "@tanstack/react-router"; +import { World } from "@ui/components/globe.tsx"; +import { Button } from "@ui/components/ui/button.tsx"; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@ui/components/ui/card.tsx"; +import { Separator } from "@ui/components/ui/separator"; +import { Book, Coins, MessageCircleMore } from "lucide-react"; + +const globeConfig = { + pointSize: 4, + globeColor: "#062056", + showAtmosphere: true, + atmosphereColor: "#FFFFFF", + atmosphereAltitude: 0.1, + emissive: "#062056", + emissiveIntensity: 0.1, + shininess: 0.9, + polygonColor: "rgba(255,255,255,0.7)", + ambientLight: "#38bdf8", + directionalLeftLight: "#ffffff", + directionalTopLight: "#ffffff", + pointLight: "#ffffff", + arcTime: 1000, + arcLength: 0.9, + rings: 1, + maxRings: 3, + initialPosition: { lat: 22.3193, lng: 114.1694 }, + autoRotate: true, + autoRotateSpeed: 0.5, +}; +const colors = ["#06b6d4", "#3b82f6", "#6366f1"]; +const sampleArcs = [ + { + order: 1, + startLat: -19.885592, + startLng: -43.951191, + endLat: -22.9068, + endLng: -43.1729, + arcAlt: 0.1, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 1, + startLat: 28.6139, + startLng: 77.209, + endLat: 3.139, + endLng: 101.6869, + arcAlt: 0.2, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 1, + startLat: -19.885592, + startLng: -43.951191, + endLat: -1.303396, + endLng: 36.852443, + arcAlt: 0.5, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 2, + startLat: 1.3521, + startLng: 103.8198, + endLat: 35.6762, + endLng: 139.6503, + arcAlt: 0.2, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 2, + startLat: 51.5072, + startLng: -0.1276, + endLat: 3.139, + endLng: 101.6869, + arcAlt: 0.3, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 2, + startLat: -15.785493, + startLng: -47.909029, + endLat: 36.162809, + endLng: -115.119411, + arcAlt: 0.3, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 3, + startLat: -33.8688, + startLng: 151.2093, + endLat: 22.3193, + endLng: 114.1694, + arcAlt: 0.3, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 3, + startLat: 21.3099, + startLng: -157.8581, + endLat: 40.7128, + endLng: -74.006, + arcAlt: 0.3, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 3, + startLat: -6.2088, + startLng: 106.8456, + endLat: 51.5072, + endLng: -0.1276, + arcAlt: 0.3, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 4, + startLat: 11.986597, + startLng: 8.571831, + endLat: -15.595412, + endLng: -56.05918, + arcAlt: 0.5, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 4, + startLat: -34.6037, + startLng: -58.3816, + endLat: 22.3193, + endLng: 114.1694, + arcAlt: 0.7, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 4, + startLat: 51.5072, + startLng: -0.1276, + endLat: 48.8566, + endLng: -2.3522, + arcAlt: 0.1, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 5, + startLat: 14.5995, + startLng: 120.9842, + endLat: 51.5072, + endLng: -0.1276, + arcAlt: 0.3, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 5, + startLat: 1.3521, + startLng: 103.8198, + endLat: -33.8688, + endLng: 151.2093, + arcAlt: 0.2, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 5, + startLat: 34.0522, + startLng: -118.2437, + endLat: 48.8566, + endLng: -2.3522, + arcAlt: 0.2, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 6, + startLat: -15.432563, + startLng: 28.315853, + endLat: 1.094136, + endLng: -63.34546, + arcAlt: 0.7, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 6, + startLat: 37.5665, + startLng: 126.978, + endLat: 35.6762, + endLng: 139.6503, + arcAlt: 0.1, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 6, + startLat: 22.3193, + startLng: 114.1694, + endLat: 51.5072, + endLng: -0.1276, + arcAlt: 0.3, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 7, + startLat: -19.885592, + startLng: -43.951191, + endLat: -15.595412, + endLng: -56.05918, + arcAlt: 0.1, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 7, + startLat: 48.8566, + startLng: -2.3522, + endLat: 52.52, + endLng: 13.405, + arcAlt: 0.1, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 7, + startLat: 52.52, + startLng: 13.405, + endLat: 34.0522, + endLng: -118.2437, + arcAlt: 0.2, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 8, + startLat: -8.833221, + startLng: 13.264837, + endLat: -33.936138, + endLng: 18.436529, + arcAlt: 0.2, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 8, + startLat: 49.2827, + startLng: -123.1207, + endLat: 52.3676, + endLng: 4.9041, + arcAlt: 0.2, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 8, + startLat: 1.3521, + startLng: 103.8198, + endLat: 40.7128, + endLng: -74.006, + arcAlt: 0.5, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 9, + startLat: 51.5072, + startLng: -0.1276, + endLat: 34.0522, + endLng: -118.2437, + arcAlt: 0.2, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 9, + startLat: 22.3193, + startLng: 114.1694, + endLat: -22.9068, + endLng: -43.1729, + arcAlt: 0.7, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 9, + startLat: 1.3521, + startLng: 103.8198, + endLat: -34.6037, + endLng: -58.3816, + arcAlt: 0.5, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 10, + startLat: -22.9068, + startLng: -43.1729, + endLat: 28.6139, + endLng: 77.209, + arcAlt: 0.7, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 10, + startLat: 34.0522, + startLng: -118.2437, + endLat: 31.2304, + endLng: 121.4737, + arcAlt: 0.3, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 10, + startLat: -6.2088, + startLng: 106.8456, + endLat: 52.3676, + endLng: 4.9041, + arcAlt: 0.3, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 11, + startLat: 41.9028, + startLng: 12.4964, + endLat: 34.0522, + endLng: -118.2437, + arcAlt: 0.2, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 11, + startLat: -6.2088, + startLng: 106.8456, + endLat: 31.2304, + endLng: 121.4737, + arcAlt: 0.2, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 11, + startLat: 22.3193, + startLng: 114.1694, + endLat: 1.3521, + endLng: 103.8198, + arcAlt: 0.2, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 12, + startLat: 34.0522, + startLng: -118.2437, + endLat: 37.7749, + endLng: -122.4194, + arcAlt: 0.1, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 12, + startLat: 35.6762, + startLng: 139.6503, + endLat: 22.3193, + endLng: 114.1694, + arcAlt: 0.2, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 12, + startLat: 22.3193, + startLng: 114.1694, + endLat: 34.0522, + endLng: -118.2437, + arcAlt: 0.3, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 13, + startLat: 52.52, + startLng: 13.405, + endLat: 22.3193, + endLng: 114.1694, + arcAlt: 0.3, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 13, + startLat: 11.986597, + startLng: 8.571831, + endLat: 35.6762, + endLng: 139.6503, + arcAlt: 0.3, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 13, + startLat: -22.9068, + startLng: -43.1729, + endLat: -34.6037, + endLng: -58.3816, + arcAlt: 0.1, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, + { + order: 14, + startLat: -33.936138, + startLng: 18.436529, + endLat: 21.395643, + endLng: 39.883798, + arcAlt: 0.3, + color: colors[Math.floor(Math.random() * (colors.length - 1))], + }, +]; + +const SignInIndexAppComponent = () => { + const items = [ + { + title: "iForge Discord", + description: "Join the iForge Discord if you haven't already.", + icon: <MessageCircleMore className="h-8 w-8 text-neutral-500" />, + linkText: "Join", + externalLink: import.meta.env.VITE_DISCORD_URL, + }, + ]; + const user = useUser(); + const isRep = user?.roles.some((role) => role.name.includes("Rep")); + const isAdmin = user?.roles.some((role) => role.name.includes("Admin")); + + if (isRep) { + items.push( + { + title: "Purchase Form", + description: "Add Items that need to be purchased to the list via the purchase form!", + icon: <Coins className="h-8 w-8 text-neutral-500" />, + linkText: "Buy", + externalLink: + "https://docs.google.com/forms/d/e/1FAIpQLScdLTE7eXqGQRa3e0UfymYo8qjlNTyu5xfIyArMG0wGQgHjyw/viewform", + }, + { + title: "iDocs", + description: "Check out your team specific documentation here (coming soon)", + icon: <Book className="h-8 w-8 text-neutral-500" />, + linkText: "Learn", + externalLink: "https://docs.iforge.shef.ac.uk", + }, + ); + } -const SignInAppIndexComponent = () => { return ( <> + <Title prompt="Signin App Home" /> <div className="p-4 mt-1"> - <Title prompt="Signin Manager" /> <ActiveLocationSelector /> - <SignInActionsManager /> + <div className="border-2 rounded-md p-4"> + <h1 className="text-xl font-bold mb-4 text-center">Sign in Home</h1> + {isAdmin && ( + <> + <div className="mb-2"> + <SignInActionsManager /> + </div> + <Separator className="mb-2" /> + </> + )} + <div className="flex flex-col-reverse md:flex-row"> + <div className="md:w-1/2 flex justify-center items-center p-4"> + <div className="aspect-square relative w-full h-full"> + <World data={sampleArcs} globeConfig={globeConfig} /> + </div> + </div> + <div className="md:w-1/2 flex justify-center items-center"> + <div className="grid grid-cols-1 gap-4 mx-auto"> + {/* Use mx-auto for horizontal centering */} + {items.map((item) => ( + <Card key={item.title} className="shadow-md rounded-md p-4 max-w-md"> + <CardHeader> + <CardTitle className="flex items-center"> + {/* Use items-center for vertical alignment */} + {item.icon} + <span className="ml-2">{item.title}</span> + </CardTitle> + </CardHeader> + <CardContent className="text-balance">{item.description}</CardContent> + <CardFooter> + <a href={item.externalLink} target="_blank" rel="noopener noreferrer"> + <Button>{item.title}</Button> + </a> + </CardFooter> + </Card> + ))} + </div> + </div> + </div> + </div> </div> </> ); }; export const Route = createFileRoute("/_authenticated/signin/")({ - beforeLoad: ({ context }) => { - if (!context.auth.user?.roles.find((role) => role.name === "Rep")) { - throw redirect({ - to: "/signin/home", - }); - } - }, - component: SignInAppIndexComponent, + component: SignInIndexAppComponent, }); diff --git a/apps/forge/src/services/signin/queueService.ts b/apps/forge/src/services/signin/queueService.ts index a3477f4..85e0a63 100644 --- a/apps/forge/src/services/signin/queueService.ts +++ b/apps/forge/src/services/signin/queueService.ts @@ -1,29 +1,24 @@ import axiosInstance from "@/api/axiosInstance.ts"; import axios from "axios"; - export interface PostQueueProps { - signal: AbortSignal; - locationName: string; - uCardNumber: number; + signal: AbortSignal; + locationName: string; + userId: string; } -export const PostQueueInPerson = async ({ - locationName, - uCardNumber, - signal - }: PostQueueProps): Promise<string> => { - try { - const {data} = await axiosInstance.post(`/location/${locationName}/queue/in-person/${uCardNumber}`, {}, {signal: signal}); - 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 new Error(error.response.data.message || "An error occurred with the API."); - } else { - // This is an Axios error (network problem, etc.) - throw error; - } +export const PostQueue = async ({ locationName, userId, signal }: PostQueueProps): Promise<string> => { + try { + const { data } = await axiosInstance.post(`/location/${locationName}/queue/add/${userId}`, {}, { signal: signal }); + 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 new Error(error.response.data.message || "An error occurred with the API."); + } else { + // This is an Axios error (network problem, etc.) + throw error; } -}; \ No newline at end of file + } +}; diff --git a/apps/forge/src/types/signInActions.ts b/apps/forge/src/types/signInActions.ts index 86e877e..d4ec9d8 100644 --- a/apps/forge/src/types/signInActions.ts +++ b/apps/forge/src/types/signInActions.ts @@ -12,11 +12,6 @@ export enum SignOutSteps { Step2 = "Sign Out", } -export enum RegisterSteps { - Step1 = "UCard Input", - Step2 = "Register", -} - export enum EnqueueSteps { Step1 = "UCard Input", Step2 = "Enqueue", @@ -25,7 +20,6 @@ export enum EnqueueSteps { export enum FlowType { SignIn = "SIGN_IN", SignOut = "SIGN_OUT", - Register = "REGISTER", Enqueue = "ENQUEUE", } @@ -39,12 +33,11 @@ export interface FlowStepComponent extends React.FC<StepComponentProps> {} export interface FlowConfiguration { [FlowType.SignIn]: Record<SignInSteps, FlowStepComponent>; [FlowType.SignOut]: Record<SignOutSteps, FlowStepComponent>; - [FlowType.Register]: Record<RegisterSteps, FlowStepComponent>; [FlowType.Enqueue]: Record<EnqueueSteps, FlowStepComponent>; } // Define a type that can be either SignInSteps or SignOutSteps. -export type AnyStep = SignInSteps | SignOutSteps | RegisterSteps | EnqueueSteps; +export type AnyStep = SignInSteps | SignOutSteps | EnqueueSteps; export const flowTypeToPrintTable = (flowType: FlowType) => { switch (flowType) { @@ -52,8 +45,6 @@ export const flowTypeToPrintTable = (flowType: FlowType) => { return "Sign In"; case FlowType.SignOut: return "Sign Out"; - case FlowType.Register: - return "Register"; case FlowType.Enqueue: return "Enqueue"; } diff --git a/apps/forge/src/types/signin.ts b/apps/forge/src/types/signin.ts index 6406138..6b3a577 100644 --- a/apps/forge/src/types/signin.ts +++ b/apps/forge/src/types/signin.ts @@ -1,3 +1,4 @@ +import { sign_in } from "@ignis/types"; import { Location, LocationStatus, Reason, Training } from "@ignis/types/sign_in.ts"; export interface SignInState { @@ -11,10 +12,9 @@ export interface SignInState { // TODO IDEALLY THIS WOULD BE A SESSION PER FLOW TYPE BUT I DON'T WANT TO REFACTOR THE WHOLE THING RN export interface SignInSession { ucard_number: string; - is_rep: boolean; + user: sign_in.User | null; sign_in_reason: Reason | null; training: Training[] | null; navigation_is_backtracking: boolean; session_errored: boolean; - username: string | null; } diff --git a/packages/types/sign_in.ts b/packages/types/sign_in.ts index 08c55e3..9e954c3 100644 --- a/packages/types/sign_in.ts +++ b/packages/types/sign_in.ts @@ -48,7 +48,7 @@ export type LocationStatus = { needs_queue: boolean; out_of_hours: boolean; count_in_queue: number; - locationName: string; + locationName: Location; }; //* Training for a user who's requesting to sign in */ @@ -60,6 +60,8 @@ export type User = users.UserWithInfractions & { training: Training[]; registered: boolean; is_rep: boolean; + signed_in: boolean; + teams?: users.ShortTeam[]; }; export type Reason = PartialReason & { diff --git a/packages/ui/components/ui/avatar.tsx b/packages/ui/components/ui/avatar.tsx index 3ae00df..6295e49 100644 --- a/packages/ui/components/ui/avatar.tsx +++ b/packages/ui/components/ui/avatar.tsx @@ -1,9 +1,9 @@ -"use client" +"use client"; -import * as React from "react" -import * as AvatarPrimitive from "@radix-ui/react-avatar" +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import * as React from "react"; -import { cn } from "@ui/lib/utils" +import { cn } from "@ui/lib/utils"; const Avatar = React.forwardRef< React.ElementRef<typeof AvatarPrimitive.Root>, @@ -11,26 +11,19 @@ const Avatar = React.forwardRef< >(({ className, ...props }, ref) => ( <AvatarPrimitive.Root ref={ref} - className={cn( - "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", - className - )} + className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)} {...props} /> -)) -Avatar.displayName = AvatarPrimitive.Root.displayName +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; const AvatarImage = React.forwardRef< React.ElementRef<typeof AvatarPrimitive.Image>, React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> >(({ className, ...props }, ref) => ( - <AvatarPrimitive.Image - ref={ref} - className={cn("aspect-square h-full w-full", className)} - {...props} - /> -)) -AvatarImage.displayName = AvatarPrimitive.Image.displayName + <AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} /> +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; const AvatarFallback = React.forwardRef< React.ElementRef<typeof AvatarPrimitive.Fallback>, @@ -39,12 +32,12 @@ const AvatarFallback = React.forwardRef< <AvatarPrimitive.Fallback ref={ref} className={cn( - "flex h-full w-full items-center justify-center rounded-full bg-muted", - className + "flex h-full w-full items-center justify-center rounded-full bg-muted text-black dark:text-white ", + className, )} {...props} /> -)) -AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; -export { Avatar, AvatarImage, AvatarFallback } +export { Avatar, AvatarImage, AvatarFallback };