From 10bbe06a54675d6d54a3a20619e8e7bbe0ab578e Mon Sep 17 00:00:00 2001 From: Gobot1234 Date: Fri, 12 Apr 2024 00:18:55 +0100 Subject: [PATCH 1/2] feat: finish off in-person training, revoking it and infractions --- apps/anvil/src/users/dto/users.dto.ts | 20 +- apps/anvil/src/users/users.controller.ts | 49 ++- apps/anvil/src/users/users.service.ts | 50 ++- apps/forge/package.json | 1 + .../dashboard/components/SignedInUserCard.tsx | 341 ++++++++++-------- .../src/services/users/addInPersonTraining.ts | 17 + .../forge/src/services/users/addInfraction.ts | 19 + .../src/services/users/revokeTraining.ts | 20 + .../ui/components/date-picker-with-range.tsx | 53 +++ pnpm-lock.yaml | 73 +--- 10 files changed, 411 insertions(+), 232 deletions(-) create mode 100644 apps/forge/src/services/users/addInPersonTraining.ts create mode 100644 apps/forge/src/services/users/addInfraction.ts create mode 100644 apps/forge/src/services/users/revokeTraining.ts create mode 100644 packages/ui/components/date-picker-with-range.tsx diff --git a/apps/anvil/src/users/dto/users.dto.ts b/apps/anvil/src/users/dto/users.dto.ts index 1adb64d..1e9cad1 100644 --- a/apps/anvil/src/users/dto/users.dto.ts +++ b/apps/anvil/src/users/dto/users.dto.ts @@ -1,8 +1,20 @@ -import { - CreateUserSchema, - UpdateUserSchema, -} from "@dbschema/edgedb-zod/modules/users"; +import { CreateInfractionSchema, CreateUserSchema, UpdateUserSchema } from "@dbschema/edgedb-zod/modules/users"; import { createZodDto } from "nestjs-zod"; +import { z } from "zod"; export class CreateUserDto extends createZodDto(CreateUserSchema) {} export class UpdateUserDto extends createZodDto(UpdateUserSchema) {} +export class CreateInfractionDto extends createZodDto(CreateInfractionSchema) {} + +export const AddInPersonTrainingSchema = z.object({ + rep_id: z.string(), + created_at: z.date(), +}); + +export class AddInPersonTrainingDto extends createZodDto(AddInPersonTrainingSchema) {} + +export const RevokeTrainingSchema = z.object({ + reason: z.string(), +}); + +export class RevokeTrainingDto extends createZodDto(RevokeTrainingSchema) {} diff --git a/apps/anvil/src/users/users.controller.ts b/apps/anvil/src/users/users.controller.ts index 897725c..d227d66 100644 --- a/apps/anvil/src/users/users.controller.ts +++ b/apps/anvil/src/users/users.controller.ts @@ -1,23 +1,26 @@ import { CheckAbilities } from "@/auth/authorization/decorators/check-abilities-decorator"; +import { IsAdmin } from "@/auth/authorization/decorators/check-roles-decorator"; import { CaslAbilityGuard } from "@/auth/authorization/guards/casl-ability.guard"; -import { TrainingService } from "@/training/training.service"; import type { UpdateUserSchema } from "@dbschema/edgedb-zod/modules/users"; +import { users } from "@ignis/types"; import type { Training, User } from "@ignis/types/users"; import { Body, Controller, Delete, Get, NotFoundException, Param, Patch, Post, UseGuards } from "@nestjs/common"; import { AuthGuard } from "@nestjs/passport"; import type { z } from "zod"; import { User as GetUser } from "../shared/decorators/user.decorator"; -import type { CreateUserDto, UpdateUserDto } from "./dto/users.dto"; +import type { + AddInPersonTrainingDto, + CreateInfractionDto, + CreateUserDto, + RevokeTrainingDto, + UpdateUserDto, +} from "./dto/users.dto"; import { UsersService } from "./users.service"; -import { users } from "@ignis/types"; @Controller("users") @UseGuards(AuthGuard("jwt"), CaslAbilityGuard) export class UsersController { - constructor( - private readonly usersService: UsersService, - private readonly trainingService: TrainingService, - ) {} + constructor(private readonly usersService: UsersService) {} @Post() @CheckAbilities(["CREATE"], "USER") @@ -32,7 +35,7 @@ export class UsersController { } @Get("me") // Get own user data - // @CheckAbilities(["READ"], "SELF") + @CheckAbilities(["READ"], "SELF") async findSelf(@GetUser() user: User) { return user; } @@ -54,7 +57,7 @@ export class UsersController { } @Get(":id") // Get any user's data (admin/higher permission) - // @CheckAbilities(['READ'], 'USER') + @CheckAbilities(["READ"], "USER") async findOne(@Param("id") id: string) { const user = await this.usersService.findOne(id); if (!user) { @@ -87,8 +90,34 @@ export class UsersController { return this.usersService.getUserTrainingInPersonTrainingRemaining(id); } + @Post(":id/training/:training_id") + @IsAdmin() + async addTraining( + @Param("id") id: string, + @Param("training_id") training_id: string, + @Body() data: AddInPersonTrainingDto, + ) { + return this.usersService.addInPersonTraining(id, training_id, data); + } + + @Delete(":id/training/:training_id") + @CheckAbilities(["READ"], "USER") + async revokeTraining( + @Param("id") id: string, + @Param("training_id") training_id: string, + @Body() data: RevokeTrainingDto, + ) { + return this.usersService.revokeTraining(id, training_id, data); + } + + @Post(":id/infractions") + @IsAdmin() + async addInfraction(@Param("id") id: string, @Body() data: CreateInfractionDto) { + return this.usersService.addInfraction(id, data); + } + @Patch(":id/promote/:teamid") - @CheckAbilities(["UPDATE"], "USER") + @IsAdmin() async promoteUser(@Param("id") id: string, @Param("teamid") teamid: string) { return this.usersService.promoteUserToRep(id, teamid); } diff --git a/apps/anvil/src/users/users.service.ts b/apps/anvil/src/users/users.service.ts index e5f3741..54a1881 100644 --- a/apps/anvil/src/users/users.service.ts +++ b/apps/anvil/src/users/users.service.ts @@ -2,14 +2,19 @@ import { GoogleUser } from "@/auth/interfaces/google-user.interface"; import { LdapUser } from "@/auth/interfaces/ldap-user.interface"; import { EdgeDBService } from "@/edgedb/edgedb.service"; import { LdapService } from "@/ldap/ldap.service"; -import { CreateUserSchema, UpdateUserSchema } from "@dbschema/edgedb-zod/modules/users"; import e from "@dbschema/edgeql-js"; import { users } from "@ignis/types"; import { Location } from "@ignis/types/sign_in"; import { RepStatus, SignInStat, Training, User } from "@ignis/types/users"; import { ConflictException, Injectable, NotFoundException } from "@nestjs/common"; -import { CardinalityViolationError, ConstraintViolationError, InvalidValueError } from "edgedb"; -import { z } from "zod"; +import { CardinalityViolationError, ConstraintViolationError, Duration, InvalidValueError } from "edgedb"; +import { + AddInPersonTrainingDto, + CreateInfractionDto, + CreateUserDto, + RevokeTrainingDto, + UpdateUserDto, +} from "./dto/users.dto"; export const PartialUserProps = e.shape(e.users.User, () => ({ // Fairly minimal, useful for templating @@ -91,7 +96,7 @@ export class UsersService { /** Insert a user into the database. Take extra care that the ucard_number is a valid thing to insert */ async create( - createUserDto: Omit, "ucard_number"> & { + createUserDto: Omit & { ucard_number: any; }, ): Promise { @@ -156,7 +161,7 @@ export class UsersService { ); } - async update(id: string, updateUserDto: z.infer): Promise { + async update(id: string, updateUserDto: UpdateUserDto): Promise { try { await this.dbService.query( e.assert_exists( @@ -343,7 +348,7 @@ export class UsersService { ); } - async addInPersonTraining(id: string, training_id: string, rep_id: string, created_at: Date) { + async addInPersonTraining(id: string, training_id: string, data: AddInPersonTrainingDto) { // pre-condition they must already have completed online training otherwise this is a no-op await this.dbService.query( e.assert_exists( @@ -353,8 +358,8 @@ export class UsersService { training: e.select(u.training, (t) => ({ filter_single: e.op(t.id, "=", training_id), "@created_at": t["@created_at"], - "@in_person_created_at": created_at, - "@in_person_signed_off_by": rep_id, + "@in_person_created_at": data.created_at, + "@in_person_signed_off_by": data.rep_id, })), }, })), @@ -362,7 +367,7 @@ export class UsersService { ); } - async revokeTraining(id: string, training_id: string) { + async revokeTraining(id: string, training_id: string, data: RevokeTrainingDto) { await this.dbService.query( e.delete(e.training.UserTrainingSession, (session) => ({ filter_single: e.all( @@ -371,11 +376,19 @@ export class UsersService { e.op( session.user, "=", - e.update(e.users.User, () => ({ + e.update(e.users.User, (user) => ({ set: { training: { "-=": e.assert_exists(e.select(e.users.User, UserTrainingEntry(id, training_id))).training, }, + infractions: { + "+=": e.insert(e.users.Infraction, { + user, + reason: data.reason, + resolved: true, + type: e.users.InfractionType.TRAINING_ISSUE, + }), + }, }, filter_single: { id }, })), @@ -386,6 +399,23 @@ export class UsersService { ); } + async addInfraction(id: string, data: CreateInfractionDto) { + await this.dbService.query( + e.update(e.users.User, (user) => ({ + set: { + infractions: { + "+=": e.insert(e.users.Infraction, { + user, + ...data, + duration: Duration.from(data.duration!), + }), + }, + }, + filter_single: { id }, + })), + ); + } + async promoteUserToRep(id: string, teamId: string, status: RepStatus = "ACTIVE") { // Assuming you have a method to check if a user is already a Rep const isAlreadyRep = await this.isRep(id); diff --git a/apps/forge/package.json b/apps/forge/package.json index 5fd3150..98434ef 100644 --- a/apps/forge/package.json +++ b/apps/forge/package.json @@ -45,6 +45,7 @@ "postcss": "^8.4.38", "react": "^18.2.0", "react-cookie": "^6.1.3", + "react-day-picker": "^8.10.0", "react-dom": "^18.2.0", "react-helmet-async": "^2.0.4", "react-hook-form": "^7.51.2", diff --git a/apps/forge/src/components/signin/dashboard/components/SignedInUserCard.tsx b/apps/forge/src/components/signin/dashboard/components/SignedInUserCard.tsx index 987f130..b3c5f86 100644 --- a/apps/forge/src/components/signin/dashboard/components/SignedInUserCard.tsx +++ b/apps/forge/src/components/signin/dashboard/components/SignedInUserCard.tsx @@ -3,16 +3,21 @@ import { INFRACTION_TYPES, REP_ON_SHIFT } from "@/lib/constants"; import { toTitleCase } from "@/lib/utils"; import { AppRootState } from "@/redux/store"; import { PostSignOut, PostSignOutProps } from "@/services/signin/signInService"; +import addInPersonTraining from "@/services/users/addInPersonTraining"; +import addInfraction from "@/services/users/addInfraction"; import { getUserTraining } from "@/services/users/getUserTraining"; import { getUserTrainingRemaining } from "@/services/users/getUserTrainingRemaining"; +import revokeTraining from "@/services/users/revokeTraining"; import type { Location, PartialReason } from "@ignis/types/sign_in"; -import type { InfractionType, PartialUser, Rep } from "@ignis/types/users"; +import type { InfractionType, PartialUser } from "@ignis/types/users"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; +import DatePickerWithRange from "@ui/components/date-picker-with-range"; import { Badge } from "@ui/components/ui/badge.tsx"; import { Button } from "@ui/components/ui/button.tsx"; import { Calendar } from "@ui/components/ui/calendar"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@ui/components/ui/card"; +import { Checkbox } from "@ui/components/ui/checkbox"; import { Label } from "@ui/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/ui/popover"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@ui/components/ui/select"; @@ -20,9 +25,10 @@ import { Separator } from "@ui/components/ui/separator"; import { Textarea } from "@ui/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@ui/components/ui/tooltip"; import { cn } from "@ui/lib/utils"; -import { format } from "date-fns"; +import { addDays, format } from "date-fns"; import { CalendarIcon, LogOut, Plus } from "lucide-react"; -import React from "react"; +import * as React from "react"; +import { DateRange } from "react-day-picker"; import { useSelector } from "react-redux"; import { toast } from "sonner"; @@ -39,132 +45,150 @@ interface AddToUserProps { onShiftReps: PartialUser[]; } -const AddToUser: React.FC = ({ user, location, onShiftReps }) => { - const [section, setSection] = React.useState("Training"); - - const [date, setDate] = React.useState(new Date()); - const [repSigningOff, setRepSigningOff] = React.useState(); +const TrainingSection: React.FC = ({ user, location, onShiftReps }) => { + const [date, setDate] = React.useState(new Date()); + const [repSigningOff, setRepSigningOff] = React.useState(); const [training, setTraining] = React.useState(); const { data: remainingTrainings } = useQuery({ queryKey: ["userTrainingRemaining", user.id], queryFn: () => getUserTrainingRemaining(user.id), }); - const [infractionType, setInfractionType] = React.useState(); - const [infractionTraining, setInfractionTraining] = React.useState(); + return ( + <> +
+ + +
+
+ +
+ + + + + + date > new Date() || date < new Date("2015-01-01")} // a fun epoch + initialFocus + /> + + +
+
+ + +
+ +
+ +
+ + ); +}; + +const InfractionSection: React.FC> = ({ user, location }) => { + const [type, setInfractionType] = React.useState("WARNING"); + const [reason, setReason] = React.useState(""); + const [resolved, setResolved] = React.useState(true); + + const [trainingToRevoke, setTrainingToRevoke] = React.useState(); const { data: trainings } = useQuery({ queryKey: ["userTraining", user.id], queryFn: () => getUserTraining(user.id), }); + const [date, setDate] = React.useState({ + from: new Date(), + to: addDays(new Date(), 7), + }); - let body; - if (section === "Training") { - body = ( - <> -
- - -
-
- -
- - - - - - date > new Date() || date < new Date("2015-01-01")} // a fun epoch - initialFocus - /> - - -
-
- - -
+ let extra_field = ( +
+ setResolved((oldValue) => !oldValue)} /> + +
+ ); + let buttonDisabled = !type; + let buttonOnClick = () => + addInfraction(user.id, { + type, + resolved, + reason, + created_at: date!.from!, + duration: + date!.from && date!.to + ? `${Math.round(date!.from!.getTime() - date!.to!.getTime() / 1000 / 60 / 60 / 24)}d` + : undefined, + }); -
- -
- - ); - } else if (section === "Infraction") { - let extra_field = undefined; - if (infractionType === "TEMP_BAN") { + switch (type) { + case "TEMP_BAN": + buttonDisabled = !(type && date?.from && date?.to); extra_field = ( -
- - -
+ <> + {extra_field} +
+ + +
+ ); - } else if (infractionType === "TRAINING_ISSUE") { + break; + case "TRAINING_ISSUE": + buttonDisabled = !(type && trainingToRevoke); + buttonOnClick = () => revokeTraining(user.id, trainingToRevoke!, { reason }); + extra_field = (
- - {remainingTrainings?.map((training) => + {trainings?.map((training) => training.locations.includes(location.toUpperCase() as Uppercase) ? ( {training.name} ) : undefined, @@ -174,42 +198,71 @@ const AddToUser: React.FC = ({ user, location, onShiftReps }) =>
); - } - body = ( - <> -
- - -
- {extra_field} -
- -