From 032370bcb3c42126213fc2362acbada8f736637b Mon Sep 17 00:00:00 2001 From: aimedivin Date: Sun, 13 Oct 2024 23:16:16 +0200 Subject: [PATCH] fix(attendance): move attendance calculation logic from client to server --- package-lock.json | 29 ++ package.json | 2 + src/index.ts | 1 + src/models/attendance.model.ts | 28 +- src/models/team.model.ts | 8 + src/models/user.ts | 24 +- src/resolvers/attendance.resolvers.ts | 460 +++++++++++++++++++++----- src/resolvers/coordinatorResolvers.ts | 90 ++--- src/resolvers/team.resolvers.ts | 26 +- src/resolvers/userResolver.ts | 32 +- src/schema/index.ts | 65 +++- src/utils/cron-jobs/team-jobs.ts | 104 ++++++ src/utils/getDateForDays.ts | 71 ++++ 13 files changed, 767 insertions(+), 173 deletions(-) create mode 100644 src/utils/cron-jobs/team-jobs.ts create mode 100644 src/utils/getDateForDays.ts diff --git a/package-lock.json b/package-lock.json index 4c08788b..18d5180f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@graphql-tools/utils": "^10.0.4", "@octokit/graphql": "^7.0.1", "@octokit/rest": "^19.0.13", + "@types/node-cron": "^3.0.11", "apollo-server": "^3.13.0", "bcryptjs": "^2.4.3", "cloudinary": "^1.30.1", @@ -34,6 +35,7 @@ "jsonwebtoken": "^9.0.2", "mongodb": "^4.17.1", "mongoose": "^6.6.1", + "node-cron": "^3.0.3", "node-fetch": "^2.6.12", "nodemailer": "^6.7.8", "normalize-mongoose": "^1.0.0", @@ -3066,6 +3068,12 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.52.tgz", "integrity": "sha512-s3nugnZumCC//n4moGGe6tkNMyYEdaDBitVjwPxXmR5lnMG5dHePinH2EdxkG3Rh1ghFHHixAG4NJhpJW1rthQ==" }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "license": "MIT" + }, "node_modules/@types/node-fetch": { "version": "2.6.11", "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz", @@ -6780,6 +6788,27 @@ "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-cron/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", diff --git a/package.json b/package.json index 52abb3b9..2db1a19b 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@graphql-tools/utils": "^10.0.4", "@octokit/graphql": "^7.0.1", "@octokit/rest": "^19.0.13", + "@types/node-cron": "^3.0.11", "apollo-server": "^3.13.0", "bcryptjs": "^2.4.3", "cloudinary": "^1.30.1", @@ -78,6 +79,7 @@ "jsonwebtoken": "^9.0.2", "mongodb": "^4.17.1", "mongoose": "^6.6.1", + "node-cron": "^3.0.3", "node-fetch": "^2.6.12", "nodemailer": "^6.7.8", "normalize-mongoose": "^1.0.0", diff --git a/src/index.ts b/src/index.ts index db069f28..9ad3fabe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,7 @@ import { IResolvers } from '@graphql-tools/utils' import invitationSchema from './schema/invitation.schema' import TableViewInvitationResolver from './resolvers/TableViewInvitationResolver' import eventSchema from './schema/event.schema' +import './utils/cron-jobs/team-jobs' const PORT: number = parseInt(process.env.PORT!) || 4000 diff --git a/src/models/attendance.model.ts b/src/models/attendance.model.ts index d869f949..eec0c7d2 100644 --- a/src/models/attendance.model.ts +++ b/src/models/attendance.model.ts @@ -18,6 +18,11 @@ const AttendanceSchema = new Schema({ teams: [ { + date: { + type: Date, + required: false, + default: () => new Date(), + }, team: { type: mongoose.Types.ObjectId, ref: 'Team', @@ -34,24 +39,33 @@ const AttendanceSchema = new Schema({ day: { type: String, enum: ['mon', 'tue', 'wed', 'thu', 'fri'], - required: true + required: true }, date: { type: Date, - required: true + required: true }, score: { type: String, - enum: ['0', '1', '2'], - required: true + enum: [0, 1, 2], + required: true }, }, ], }, - ],} + ], + } ], - -}) +}, { timestamps: true }) + +AttendanceSchema.index( + { + phase: 1, + cohort: 1, + createdAt: 1 + }, + { unique: true } +); const Attendance = mongoose.model('Attendance', AttendanceSchema) export { Attendance } diff --git a/src/models/team.model.ts b/src/models/team.model.ts index b4af4170..5c140f02 100644 --- a/src/models/team.model.ts +++ b/src/models/team.model.ts @@ -1,10 +1,13 @@ import mongoose, { Schema } from 'mongoose' import { User } from './user' import { CohortInterface } from './cohort.model'; +import { PhaseInterface } from './phase.model'; export interface TeamInterface { + _id: mongoose.Types.ObjectId; name: string; cohort?: CohortInterface; + phase?: PhaseInterface; ttl?: mongoose.Types.ObjectId; members: mongoose.Types.ObjectId[]; startingPhase: Date; @@ -57,6 +60,11 @@ const teamSchema = new Schema( type: mongoose.Types.ObjectId, ref: 'Program', }, + isJobActive: { + type: Boolean, + default: true, + required: true, + }, }, { statics: { diff --git a/src/models/user.ts b/src/models/user.ts index bc59a9ed..3eee3ae1 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -9,17 +9,19 @@ export interface UserStatus { } export interface UserInterface { - _id: mongoose.Types.ObjectId - email: string - password: string - role: string - team?: mongoose.Types.ObjectId - status: UserStatus - cohort?: mongoose.Types.ObjectId - program?: mongoose.Types.ObjectId - organizations: string[] - pushNotifications: boolean - emailNotifications: boolean + _id: mongoose.Types.ObjectId; + email: string; + password: string; + role: string; + team?: mongoose.Types.ObjectId; + status: UserStatus; + cohort?: mongoose.Types.ObjectId; + program?: mongoose.Types.ObjectId; + organizations: string[]; + pushNotifications: boolean; + emailNotifications: boolean; + profile?: mongoose.Types.ObjectId; + ratings?: mongoose.Types.ObjectId[]; } export enum RoleOfUser { diff --git a/src/resolvers/attendance.resolvers.ts b/src/resolvers/attendance.resolvers.ts index 4573e09c..31946d10 100644 --- a/src/resolvers/attendance.resolvers.ts +++ b/src/resolvers/attendance.resolvers.ts @@ -2,33 +2,296 @@ import { Attendance } from '../models/attendance.model' import { IntegerType, ObjectId } from 'mongodb' import { Context } from './../context' -import mongoose, { Error, Types } from 'mongoose' +import mongoose, { Document, Error, Types } from 'mongoose' import { checkUserLoggedIn } from '../helpers/user.helpers' import { pushNotification } from '../utils/notification/pushNotification' -import Phase from '../models/phase.model' +import Phase, { PhaseInterface } from '../models/phase.model' import { RoleOfUser, User, UserInterface } from '../models/user' -import Team from '../models/team.model' +import Team, { TeamInterface } from '../models/team.model' import { CohortInterface } from '../models/cohort.model' import { GraphQLError } from 'graphql' import { checkLoggedInOrganization } from '../helpers/organization.helper' +import { getDateForDays } from '../utils/getDateForDays' +import { isSameWeek } from 'date-fns' +import { id } from 'date-fns/locale' interface TraineeAttendanceStatus { day: 'mon' | 'tue' | 'wed' | 'thu' | 'fri' - score: '0' | '1' | '2' + date: string + score: 0 | 1 | 2 } interface TraineeAttendanceData { trainee: ObjectId - status: TraineeAttendanceStatus + score: number } +interface TeamAttendanceData { + week: number + phase: PhaseInterface + cohort: CohortInterface + teams: Array<{ + team: TeamInterface + date: string + trainees: Array<{ + trainee: UserInterface + status: Array + } + > + }> + createdAt: string +} + interface AttendanceInput { week: string + day: string team: string - date?: string phase?: string + today: boolean + yesterday: boolean trainees: TraineeAttendanceData[] orgToken: string } +interface TraineeAttendanceDataInterface { + trainee?: { + id: string; + email: string; + status: { + status: string + }; + profile: { + name: string; + }; + }; + score?: number; +} +interface DayInterface { + date: string + isValid: boolean +} +export interface WeekdaysInterface { + mon: DayInterface; + tue: DayInterface; + wed: DayInterface; + thu: DayInterface; + fri: DayInterface; +} +interface TraineeAttendanceDayInterface { + week: number; + phase: { + id: string + name: string + }; + dates: WeekdaysInterface; + days: { + mon: TraineeAttendanceDataInterface[]; + tue: TraineeAttendanceDataInterface[]; + wed: TraineeAttendanceDataInterface[]; + thu: TraineeAttendanceDataInterface[]; + fri: TraineeAttendanceDataInterface[]; + }; +} + +interface AttendanceWeeksInterface { + phase: { + name: string, + id: string + } + weeks: Array +} + +const formatAttendanceData = (data: TeamAttendanceData[], teamData: TeamInterface) => { + const tempPhases: PhaseInterface[] = []; + let lastDayDate = ''; + const attendanceWeeks: AttendanceWeeksInterface[] = [] + const phase = { + id: teamData.phase?._id.toString() ||teamData.cohort!.phase._id.toString(), + name: teamData.phase?.name ||teamData.cohort!.phase.name + } + + const attendanceResult: TraineeAttendanceDayInterface[] = []; + data.forEach(attendance => { + + const result: TraineeAttendanceDayInterface = { + week: attendance.week, + dates: { + mon: { + date: '', + isValid: false + }, + tue: { + date: '', + isValid: false + }, + wed: { + date: '', + isValid: false + }, + thu: { + date: '', + isValid: false + }, + fri: { + date: '', + isValid: false + }, + }, + phase: { + id: attendance.phase._id.toString(), + name: attendance.phase.name + }, + days: { + mon: [], + tue: [], + wed: [], + thu: [], + fri: [], + }, + }; + + // Store all attendance weeks + let isWeekSet = false + attendanceWeeks.forEach((week, index) => { + if (week.phase.id === attendance.phase._id.toString()) { + isWeekSet = true; + attendanceWeeks[index].weeks.push(attendance.week) + } + }) + + !isWeekSet && attendanceWeeks.push({ + phase: { + id: attendance.phase._id.toString(), + name: attendance.phase.name + }, + weeks: [attendance.week] + }) + + if (!tempPhases.find((p) => p._id.equals(attendance.phase._id))) + tempPhases.push(attendance.phase); + let date = attendance.teams[0].date; + + attendance.teams[0].trainees.forEach((traineeData) => { + if (traineeData.status.length && traineeData.trainee.status.status !== 'drop') { + traineeData.status.forEach((traineeStatus) => { + if (traineeStatus.date && !date) { + date = traineeStatus.date; + } + + result.days[ + traineeStatus.day as 'mon' | 'tue' | 'wed' | 'thu' | 'fri' + ].push({ + trainee: { + ...(traineeData.trainee as unknown as Document).toObject(), + profile: { + name: (traineeData.trainee.profile! as any).name + }, + id: traineeData.trainee._id.toString(), + }, + score: traineeStatus.score, + }); + }); + } + }); + result.dates = getDateForDays(date); + lastDayDate = result.dates.fri.date; + attendanceResult.push(result); + }); + + const phaseIds = tempPhases.map((phase) => phase._id.toString()); + let isDataSet = false; + if (!data.length) { + isDataSet = true; + attendanceWeeks.push({ + phase: { + id: phase.id, + name: phase.id + }, + weeks: [1] + }) + attendanceResult.push( + { + week: 1, + phase: { + id: phase.id, + name: phase.id + }, + dates: getDateForDays(Date.now().toString()), + days: { + mon: [], + tue: [], + wed: [], + thu: [], + fri: [], + }, + }, + ) + } + + if (!phaseIds.includes(phase.id)) { + tempPhases.push(teamData.phase || teamData.cohort!.phase); + + if (!isDataSet) { + attendanceWeeks.push({ + phase: { + id: phase.id, + name: phase.id + }, + weeks: [1] + }) + attendanceResult.push({ + week: 1, + phase: { + id: phase.id, + name: phase.id + }, + dates: getDateForDays(Date.now().toString()), + days: { + mon: [], + tue: [], + wed: [], + thu: [], + fri: [], + }, + }) + } + } + // if (!isDataSet && lastDayDate && phaseIds.includes(phase.id)) { + // const isInSameWeek = isSameWeek( + // new Date(lastDayDate), + // new Date(Date.now()), + // { + // weekStartsOn: 1, + // } + // ) + + // const attendanceWeekIndex = attendanceWeeks.findIndex(week => week.phase.id === (phase.id)); + + // if (!isInSameWeek && attendanceWeekIndex !== -1) { + // const newWeek = attendanceWeeks[attendanceWeekIndex].weeks[attendanceWeeks[attendanceWeekIndex].weeks.length - 1] + 1; + // attendanceWeeks[attendanceWeekIndex].weeks.push(newWeek) + // attendanceResult.push({ + // week: newWeek, + // phase: { + // id: phase.id, + // name: phase.id + // }, + // dates: getDateForDays(Date.now().toString()), + // days: { + // mon: [], + // tue: [], + // wed: [], + // thu: [], + // fri: [], + // }, + // }) + // } + // } + + const today = new Date(); + const yesterday = new Date().getDay() === 1 ? new Date().setDate(new Date().getDate() - 3) : new Date().setDate(new Date().getDate() - 1); + + return { attendanceWeeks, attendance: attendanceResult, today, yesterday } +}; + const validateAttendance = async ( team: string, orgToken: string, @@ -37,33 +300,46 @@ const validateAttendance = async ( ) => { const org = await checkLoggedInOrganization(orgToken) if (!org) { - throw new Error("Orgnasation doesn't exist") + throw new Error('Organisation doesn\'t exist') } - ;(await checkUserLoggedIn(context))(['coordinator']) + ; (await checkUserLoggedIn(context))(['coordinator', 'ttl']) const teamData = await Team.findById(team) + .populate({ + path: 'members', + match: { role: 'trainee' } + }) .populate('cohort') + .populate('phase') .populate('cohort.phase') if (!teamData) { - throw new Error("Team provided doesn't exist") + throw new Error('Team provided doesn\'t exist') } + const phaseData = await Phase.findById( - (teamData.cohort as CohortInterface).phase._id + teamData.phase || (teamData.cohort as CohortInterface).phase._id ) + console.log(phaseData) if (!phaseData) { - throw new Error("Phase provided doesn't exist") + throw new Error('Phase provided doesn\'t exist') } - trainees.forEach((trainee) => { - if ( - trainee.status.day.toLowerCase() !== trainees[0].status.day.toLowerCase() - ) { - throw new GraphQLError( - 'Please make sure, you submit same date for each trainee ', - { + teamData.members.forEach((member) => { + const trainee = member as UserInterface; + if (trainee.role === 'trainee' && trainee.status.status === 'active') { + const sentTestTrainee = trainees.find(traineeData => trainee._id.equals(traineeData.trainee)) + if (!sentTestTrainee) { + throw new GraphQLError('Please ensure attendance is taken for all active trainees in the team', { extensions: { - code: 'INCONSISTENT_TRAINEE_ATTENDANCE_DATE', + code: 'INCONSISTENT_TRAINEE_ATTENDANCE', }, - } - ) + }) + } + if (![0, 1, 2].includes(sentTestTrainee.score)) { + throw new GraphQLError('Attendance cannot be recorded due to an invalid score for one of trainees.', { + extensions: { + code: 'INVALID_TRAINEE_SCORE', + }, + }) + } } }) return { @@ -77,16 +353,21 @@ const returnAttendanceData = async (teamData: any) => { .populate('phase') .populate('cohort') .populate('teams.team') - .populate('teams.trainees.trainee', '-password') + .populate({ + path: 'teams.trainees.trainee', + select: '-password', + populate: { + path: 'profile', + } + }) const sanitizedAttendance: any[] = [] attendances.forEach((attendance) => { + const result = attendance.teams.find((teamAttendanceData) => (teamAttendanceData.team as ObjectId).equals(teamData.id) ) - const filteredTrainees = result?.trainees.filter( - (trainee) => (trainee.trainee as UserInterface).status.status !== 'drop' - ) + const filteredTrainees = result?.trainees.filter(trainee => (trainee.trainee as UserInterface).status.status !== 'drop') result && sanitizedAttendance.push({ @@ -100,18 +381,10 @@ const returnAttendanceData = async (teamData: any) => { ...(attendance.phase as mongoose.Document).toObject(), id: (attendance.phase as mongoose.Document)._id, }, - teams: [ - { - team: { - ...(result.team as mongoose.Document).toObject(), - id: (result.team as mongoose.Document)._id, - }, - trainees: filteredTrainees, - }, - ], + teams: [{ date: result.date, team: { ...(result.team as mongoose.Document).toObject(), id: (result.team as mongoose.Document)._id }, trainees: filteredTrainees }], }) }) - return sanitizedAttendance + return formatAttendanceData(sanitizedAttendance, teamData) } const attendanceResolver = { @@ -121,7 +394,7 @@ const attendanceResolver = { { traineeEmail }: any, context: Context ) { - ;(await checkUserLoggedIn(context))([RoleOfUser.TRAINEE]) + ; (await checkUserLoggedIn(context))([RoleOfUser.TRAINEE]) const attendance = await Attendance.find() const weeklyAttendance = attendance.map((week: any) => { @@ -140,25 +413,26 @@ const attendanceResolver = { { team }: { team: string }, context: Context ) { - ;(await checkUserLoggedIn(context))([RoleOfUser.COORDINATOR]) - const { userId } = (await checkUserLoggedIn(context))([ - RoleOfUser.COORDINATOR, - ]) + (await checkUserLoggedIn(context))([RoleOfUser.COORDINATOR, RoleOfUser.TTL]) const teamData = await Team.findById(team) + .populate({ + path: 'cohort', + populate: { + path: 'phase' + } + }) if (!teamData) { - throw new Error("Team provided doesn't exist") + throw new Error('Team provided doesn\'t exist') } return returnAttendanceData(teamData) }, async getAttendanceStats(_: any, args: any, context: Context) { - ;(await checkUserLoggedIn(context))([RoleOfUser.COORDINATOR]) - const { userId } = (await checkUserLoggedIn(context))([ - RoleOfUser.COORDINATOR, - ]) + ; (await checkUserLoggedIn(context))([RoleOfUser.COORDINATOR]) + const { userId } = (await checkUserLoggedIn(context))([RoleOfUser.COORDINATOR]) const attendances: any = await Attendance.find({ coordinatorId: userId }) //calculate statistic @@ -215,15 +489,44 @@ const attendanceResolver = { Mutation: { async recordAttendance( _: any, - { week, trainees, team, date, orgToken }: AttendanceInput, + { week, trainees, team, today, yesterday, orgToken }: AttendanceInput, context: Context ) { + const { teamData, phaseData } = await validateAttendance( team, orgToken, trainees, context ) + + if (!today && !yesterday) { + throw new Error('Recording attendance is only allowed for today and the day before within work days.') + } + if (today && yesterday) { + throw new Error('Please select either today or yesterday, not both.') + } + let date = (today && new Date()).toString(); + + if (yesterday) { + const today = new Date(); + if (today.getDay() === 1) { + const lastFriday = new Date(today); + + lastFriday.setDate(today.getDate() - 3); + date = lastFriday.toString() + } else { + const previousDay = new Date(today); + previousDay.setDate(today.getDate() - 1); + date = previousDay.toString() + } + } + + // Check if the day is among work days + if (![1, 2, 3, 4, 5].includes(new Date(date).getDay())) { + throw new Error('Attendance can only be recorded on workdays.') + } + const attendance = await Attendance.findOne({ phase: phaseData.id, week, @@ -249,9 +552,9 @@ const attendanceResolver = { trainee: traineeData, status: [ { - ...trainees[i].status, - date: new Date(date!), - day: trainees[i].status.day.toLowerCase(), + date: new Date(date), + score: trainees[i].score, + day: new Date(date).toLocaleDateString('en-US', { weekday: 'short' }).toLowerCase(), }, ], }) @@ -260,7 +563,7 @@ const attendanceResolver = { if (!attendants.length) { throw new Error( - "Invalid Ids for trainees or trainees doesn't belong to the team" + 'Invalid Ids for trainees or trainees doesn\'t belong to the team' ) } @@ -278,7 +581,7 @@ const attendanceResolver = { } const savedAttendance = await Attendance.create(newAttendance) - return savedAttendance.teams[0] + return returnAttendanceData(teamData) } // Adding new team to week attendance @@ -310,25 +613,20 @@ const attendanceResolver = { ].trainees.findIndex((traineeData) => (traineeData.trainee as UserInterface)._id.equals(trainees[i].trainee) ) - if (traineeIndex === -1) { + traineeStatusUpdated = true; const traineeData = await User.findOne( { _id: new ObjectId(trainees[i].trainee), team: teamData.id }, { password: 0 } ) if (traineeData) { - ;(attendance.teams[attendanceTeamIndex!].trainees as any[]).push({ + ; (attendance.teams[attendanceTeamIndex!].trainees as any[]).push({ trainee: traineeData, status: [ { - day: trainees[i].status.day.toLowerCase() as - | 'mon' - | 'tue' - | 'wed' - | 'thu' - | 'fri', - date: new Date(date!), - score: trainees[i].status.score as '0' | '1' | '2', + day: new Date(date).toLocaleDateString('en-US', { weekday: 'short' }).toLowerCase(), + date: new Date(date), + score: trainees[i].score, }, ], }) @@ -350,7 +648,7 @@ const attendanceResolver = { const existingDay = attendance.teams[attendanceTeamIndex!].trainees[ traineeIndex - ].status.find((s) => s.day === trainees[i].status.day.toLowerCase()) + ].status.find((s) => s.day === new Date(date).toLocaleDateString('en-US', { weekday: 'short' }).toLowerCase()) if ( ( @@ -363,14 +661,14 @@ const attendanceResolver = { attendance.teams[attendanceTeamIndex!].trainees[ traineeIndex ].status.push({ - day: trainees[i].status.day.toLowerCase() as + day: new Date(date).toLocaleDateString('en-US', { weekday: 'short' }).toLowerCase() as | 'mon' | 'tue' | 'wed' | 'thu' | 'fri', date: new Date(date!), - score: trainees[i].status.score, + score: trainees[i].score, }) } } @@ -386,27 +684,27 @@ const attendanceResolver = { ) } - const savedTeamAttendance = await ( - await attendance.save() - ).populate('teams.team') - return savedTeamAttendance.teams[attendanceTeamIndex!] + await attendance.save(); + return returnAttendanceData(teamData) }, async updateAttendance( _: any, - { week, trainees, team, orgToken, phase }: AttendanceInput, + { week, trainees, team, orgToken, day, phase }: AttendanceInput, context: Context ) { + const { teamData } = await validateAttendance( team, orgToken, trainees, context ) + const phaseData = await Phase.findById(phase) if (!phaseData) { - throw new Error("Phase provided doesn't exist") + throw new Error('Phase provided doesn\'t exist') } const attendance = await Attendance.findOne({ phase: phaseData.id, @@ -417,6 +715,7 @@ const attendanceResolver = { (teamAttendanceData) => (teamAttendanceData.team as ObjectId).equals(teamData.id) ) + if (!attendance || teamToUpdateIndex === -1) { throw new GraphQLError('Invalid week or team', { extensions: { @@ -447,18 +746,20 @@ const attendanceResolver = { } ) } + if (traineeIndex !== -1 && !isDropped) { const traineeToUpdateStatus = teamAttendanceTrainees.trainees[traineeIndex].status traineeToUpdateStatus.forEach((status) => { if ( - status.day === trainees[traineeIndex].status.day.toLowerCase() + status.day === day.toLowerCase() ) { - status.score = trainees[traineeIndex].status.score + status.score = trainees[traineeIndex].score } }) } }) + await attendance.save() return returnAttendanceData(teamData) }, @@ -468,14 +769,14 @@ const attendanceResolver = { { week, day, team }: { week: string; day: string; team: string }, context: Context ) { - ;(await checkUserLoggedIn(context))(['coordinator']) - + (await checkUserLoggedIn(context))(['coordinator', 'ttl']) + const teamData = await Team.findById(team) - .populate('cohort') - .populate('cohort.phase') + .populate('cohort') + .populate('cohort.phase') if (!teamData) { - throw new Error("Team provided doesn't exist") + throw new Error('Team provided doesn\'t exist') } const attendance = await Attendance.findOne({ @@ -489,7 +790,7 @@ const attendanceResolver = { ) if (!attendance || attendanceTeamIndex === -1) { - throw new Error("Can't find the Attendance for this day") + throw new Error('Can\'t find the Attendance for this day') } let removedAttendances = 0 @@ -507,8 +808,9 @@ const attendanceResolver = { await attendance.save() return returnAttendanceData(teamData) } + - throw new Error("Can't find the Attendance for this day") + throw new Error('Can\'t find the Attendance for this day') }, }, } diff --git a/src/resolvers/coordinatorResolvers.ts b/src/resolvers/coordinatorResolvers.ts index e13932fe..361f0ca1 100644 --- a/src/resolvers/coordinatorResolvers.ts +++ b/src/resolvers/coordinatorResolvers.ts @@ -431,51 +431,51 @@ const manageStudentResolvers = { if (!user.team) { // add trainee to attendance - if (role === RoleOfUser.COORDINATOR) { - const attendanceRecords: any = Attendance.find({ - coordinatorId: userId, - }) - - const traineeArray = (await attendanceRecords).map( - (data: any) => data.trainees - ) - - let traineeEmailExists = false - for (const weekTrainees of traineeArray) { - for (const trainee of weekTrainees) { - if (trainee.traineeEmail === email) { - traineeEmailExists = true - break - } - } - } - if (!traineeEmailExists) { - // create new trainee - const newTrainee: Trainee = { - traineeId: user.id, - traineeEmail: email, - status: [], - } - - const attendanceLength: any = await Attendance.find({ - coordinatorId: userId, - }) - - if (attendanceLength.length > 0) { - for (const attendData of attendanceLength) { - attendData.trainees.push(newTrainee) - await attendData.save() - } - } else { - const newAttendRecord = new Attendance({ - week: 1, - coordinatorId: [userId], - trainees: [newTrainee], - }) - await newAttendRecord.save() - } - } - } + // if (role === RoleOfUser.COORDINATOR) { + // const attendanceRecords: any = Attendance.find({ + // coordinatorId: userId, + // }) + + // const traineeArray = (await attendanceRecords).map( + // (data: any) => data.trainees + // ) + + // let traineeEmailExists = false + // for (const weekTrainees of traineeArray) { + // for (const trainee of weekTrainees) { + // if (trainee.traineeEmail === email) { + // traineeEmailExists = true + // break + // } + // } + // } + // if (!traineeEmailExists) { + // // create new trainee + // const newTrainee: Trainee = { + // traineeId: user.id, + // traineeEmail: email, + // status: [], + // } + + // const attendanceLength: any = await Attendance.find({ + // coordinatorId: userId, + // }) + + // if (attendanceLength.length > 0) { + // for (const attendData of attendanceLength) { + // attendData.trainees.push(newTrainee) + // await attendData.save() + // } + // } else { + // const newAttendRecord = new Attendance({ + // week: 1, + // coordinatorId: [userId], + // trainees: [newTrainee], + // }) + // await newAttendRecord.save() + // } + // } + // } user.team = team.id user.cohort = team.cohort.id diff --git a/src/resolvers/team.resolvers.ts b/src/resolvers/team.resolvers.ts index 7e67e68d..fdc2d48f 100644 --- a/src/resolvers/team.resolvers.ts +++ b/src/resolvers/team.resolvers.ts @@ -48,6 +48,23 @@ const resolvers = { }, }, Query: { + getTTLTeams: async (_: any, { orgToken }: any, context: Context) => { + try { + const { userId } = (await checkUserLoggedIn(context))([RoleOfUser.TTL]) + const org = await checkLoggedInOrganization(orgToken) + + const teams = await Team.find({ organization: org, ttl: userId }).populate('phase') + return teams; + + } catch (error) { + const { message } = error as { message: any } + throw new GraphQLError(message.toString(), { + extensions: { + code: '500', + }, + }) + } + }, getAllTeams: async (_: any, { orgToken }: any, context: Context) => { try { // some validations @@ -156,7 +173,6 @@ const resolvers = { }, }) ).filter((item: any) => { - console.log(item) const org = (item.program as InstanceType) ?.organization @@ -271,8 +287,8 @@ const resolvers = { try { const { name, cohortName, orgToken, startingPhase, ttlEmail } = args - // some validations - ;(await checkUserLoggedIn(context))([RoleOfUser.SUPER_ADMIN, RoleOfUser.ADMIN, RoleOfUser.MANAGER]) + // some validations + ; (await checkUserLoggedIn(context))([RoleOfUser.SUPER_ADMIN, RoleOfUser.ADMIN, RoleOfUser.MANAGER]) const cohort = await Cohort.findOne({ name: cohortName }) const organ = await checkLoggedInOrganization(orgToken) @@ -346,7 +362,7 @@ const resolvers = { } }, deleteTeam: async (parent: any, args: any, context: Context) => { - ;(await checkUserLoggedIn(context))([RoleOfUser.ADMIN, RoleOfUser.MANAGER]) + ; (await checkUserLoggedIn(context))([RoleOfUser.ADMIN, RoleOfUser.MANAGER]) const findTeam = await Team.findById(args.id) if (!findTeam) throw new Error('The Team you want to delete does not exist') @@ -362,8 +378,6 @@ const resolvers = { await Team.findByIdAndDelete({ _id: args.id }) cohort ? (cohort.teams = cohort.teams - 1) : null cohort?.save() - cohort && - console.log('done-----------------', cohort.coordinator.toString()) const senderId = new Types.ObjectId(context.userId) cohort && diff --git a/src/resolvers/userResolver.ts b/src/resolvers/userResolver.ts index 56324a56..b209a9dc 100644 --- a/src/resolvers/userResolver.ts +++ b/src/resolvers/userResolver.ts @@ -34,6 +34,7 @@ import organizationRejectedTemplate from '../utils/templates/organizationRejecte import registrationRequest from '../utils/templates/registrationRequestTemplate' import { EmailPattern } from '../utils/validation.utils' import { Context } from './../context' +import { UserInputError } from 'apollo-server' const octokit = new Octokit({ auth: `${process.env.Org_Repo_Access}` }) const SECRET: string = process.env.SECRET ?? 'test_secret' @@ -49,7 +50,7 @@ enum Status { const resolvers: any = { Query: { async getOrganizations(_: any, __: any, context: Context) { - ;(await checkUserLoggedIn(context))([RoleOfUser.SUPER_ADMIN]) + ; (await checkUserLoggedIn(context))([RoleOfUser.SUPER_ADMIN]) return Organization.find() }, @@ -103,7 +104,7 @@ const resolvers: any = { { organisation, username }: any, context: Context ) { - ;(await checkUserLoggedIn(context))([ + ; (await checkUserLoggedIn(context))([ RoleOfUser.ADMIN, RoleOfUser.COORDINATOR, 'trainee', @@ -115,7 +116,7 @@ const resolvers: any = { name: organisation, }) if (!organisationExists) - throw new Error("This Organization doesn't exist") + throw new Error('This Organization doesn\'t exist') organisation = organisationExists.gitHubOrganisation @@ -196,6 +197,12 @@ const resolvers: any = { } }, }, + Login: { + user: async (parent: any) => { + const user = await User.findById(parent.user.id) + return user; + } + }, Mutation: { async createUser( _: any, @@ -328,8 +335,7 @@ const resolvers: any = { }) } else if (user?.status?.status !== 'active') { throw new GraphQLError( - `Your account have been ${ - user?.status?.status ?? user?.status + `Your account have been ${user?.status?.status ?? user?.status }, please contact your organization admin for assistance`, { extensions: { @@ -582,9 +588,9 @@ const resolvers: any = { ] const org = await checkLoggedInOrganization(orgToken) const roleExists = allRoles.includes(name) - if (!roleExists) throw new Error("This role doesn't exist") + if (!roleExists) throw new Error('This role doesn\'t exist') const userExists = await User.findById(id) - if (!userExists) throw new Error("User doesn't exist") + if (!userExists) throw new Error('User doesn\'t exist') const getAllUsers = await User.find({ role: RoleOfUser.ADMIN, @@ -810,7 +816,7 @@ const resolvers: any = { context: Context ) { // check if requester is super admin - ;(await checkUserLoggedIn(context))([RoleOfUser.SUPER_ADMIN]) + ; (await checkUserLoggedIn(context))([RoleOfUser.SUPER_ADMIN]) const orgExists = await Organization.findOne({ name: name }) if (action == 'approve') { if (!orgExists) { @@ -880,7 +886,7 @@ const resolvers: any = { context: Context ) { // the below commented line help to know if the user is an superAdmin to perform an action of creating an organization - ;(await checkUserLoggedIn(context))([RoleOfUser.SUPER_ADMIN]) + ; (await checkUserLoggedIn(context))([RoleOfUser.SUPER_ADMIN]) if (action == 'new') { const orgExists = await Organization.findOne({ name: name }) if (orgExists) { @@ -945,7 +951,7 @@ const resolvers: any = { { name, gitHubOrganisation }: any, context: Context ) { - ;(await checkUserLoggedIn(context))([ + ; (await checkUserLoggedIn(context))([ RoleOfUser.ADMIN, RoleOfUser.SUPER_ADMIN, ]) @@ -1038,7 +1044,7 @@ const resolvers: any = { }, async deleteOrganization(_: any, { id }: any, context: Context) { - ;(await checkUserLoggedIn(context))([ + ; (await checkUserLoggedIn(context))([ RoleOfUser.ADMIN, RoleOfUser.SUPER_ADMIN, ]) @@ -1046,7 +1052,7 @@ const resolvers: any = { const organizationExists = await Organization.findOne({ _id: id }) if (!organizationExists) - throw new Error("This Organization doesn't exist") + throw new Error('This Organization doesn\'t exist') await Cohort.deleteMany({ organization: id }) await Team.deleteMany({ organization: id }) await Phase.deleteMany({ organization: id }) @@ -1124,7 +1130,7 @@ const resolvers: any = { if (password === confirmPassword) { const user: any = await User.findOne({ email }) if (!user) { - throw new Error("User doesn't exist! ") + throw new Error('User doesn\'t exist! ') } user.password = password await user.save() diff --git a/src/schema/index.ts b/src/schema/index.ts index 104941a3..322e692e 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -312,7 +312,8 @@ const Schema = gql` fetchRatingByCohort(CohortName: String): [Rating] fetchCohortsCoordinator(cohortName: ID!): [Cohort] verifyResetPasswordToken(token: String!): String - getAllTeams(orgToken: String): [Team!] + getTTLTeams(orgToken: String): [Team!]! + getAllTeams(orgToken: String): [Team!]! getAllTeamInCohort(orgToken: String, cohort: String): [Team!] gitHubActivity(organisation: String!, username: String!): GitHubActivity! } @@ -633,8 +634,47 @@ const Schema = gql` attendancePerc: String! } + type AttendanceDatesData { + date: String! + isValid: Boolean! + } + type AttendanceDates { + mon: AttendanceDatesData! + tue: AttendanceDatesData! + wed: AttendanceDatesData! + thu: AttendanceDatesData! + fri: AttendanceDatesData! + } + type TraineeAttendanceData { + trainee: User! + score: Int! + } + type AttendanceDays { + mon: [TraineeAttendanceData]! + tue: [TraineeAttendanceData]! + wed: [TraineeAttendanceData]! + thu: [TraineeAttendanceData]! + fri: [TraineeAttendanceData]! + } + type AttendanceWeeks { + phase: Phase! + weeks: [Int!] + } + type FilteredAttendance { + week: Int! + phase: Phase! + dates: AttendanceDates! + days: AttendanceDays! + } + type SanitizedAttendance { + today: String! + yesterday: String! + attendanceWeeks: [AttendanceWeeks!]! + attendance: [FilteredAttendance!]! + } + type Query { - getTeamAttendance(orgToken: String, team: String!): [Attendance] + getTeamAttendance(orgToken: String, team: String!): SanitizedAttendance getTraineeAttendanceByID(traineeEmail: String!): [weeklyAttendance] getAttendanceStats(orgToken: String!): [AttendanceStats] } @@ -642,30 +682,31 @@ const Schema = gql` recordAttendance( week: Int! team: String! - date: String! + today: Boolean! + yesterday: Boolean! trainees: [TraineeInput!]! orgToken: String! - ): AttendanceTeam + ): SanitizedAttendance updateAttendance( week: Int! + day: String! team: String! phase: String! trainees: [TraineeInput!]! orgToken: String! - ): [Attendance] + ): SanitizedAttendance - deleteAttendance(week: String!, team: String!, day: String!): [Attendance] - } - - input StatusInput { - day: String! - score: String! + deleteAttendance( + week: Int!, + team: String!, + day: String! + ): SanitizedAttendance } input TraineeInput { trainee: ID! - status: StatusInput! + score: Int! } type Session { id: String diff --git a/src/utils/cron-jobs/team-jobs.ts b/src/utils/cron-jobs/team-jobs.ts new file mode 100644 index 00000000..fa46dfc6 --- /dev/null +++ b/src/utils/cron-jobs/team-jobs.ts @@ -0,0 +1,104 @@ +import cron from 'node-cron'; +import Team, { TeamInterface } from '../../models/team.model'; +import { Attendance } from '../../models/attendance.model'; +import { CohortInterface } from '../../models/cohort.model'; +import { isSameWeek } from 'date-fns'; + +cron.schedule('*/20 * * * * *', async () => { + // Schedule a job to add a new week on monday + // cron.schedule('0 0 * * 1', async () => { + try { + console.log('in') + const teams = await Team.find({ isJobActive: true, active: true }).populate('cohort'); + for (const team of teams) { + const phase = team.phase || (team.cohort as CohortInterface).phase; + const mainAttendances = await Attendance.find({ + phase, + cohort: team.cohort + }); + + const attendances = await Attendance.find({ + phase, + cohort: team.cohort, + createdAt: { + $gte: new Date(new Date().toISOString().split('T')[0] + 'T00:00:00Z'), + $lt: new Date(new Date().toISOString().split('T')[0] + 'T23:59:59Z') + } + }); + + if (!attendances.length && !mainAttendances.length) { + await Attendance.create({ + week: 1, + phase: phase, + cohort: team.cohort, + teams: [{ + team: team._id, + trainees: [] + }] + }) + } else { + for (const attendance of attendances) { + let teamWeeks = 0; + const teamAttendanceData = attendance.teams.find(teamAttendance => (teamAttendance.team as TeamInterface)._id.equals(team._id)); + mainAttendances.forEach(async (mainTeamAttendance) => { + if(mainTeamAttendance.week === 1) { + if(!mainTeamAttendance.teams.find(teamAttendance => (teamAttendance.team as TeamInterface)._id.equals(team._id))) { + teamWeeks++ + } + await mainTeamAttendance.save() + } + }); + + // search document to add the team + const tempAttendances = await Attendance.find({ + week: (teamWeeks + 1), + phase, + cohort: team.cohort, + }); + if (tempAttendances) { + attendance.teams.push({ + team: team.id, + trainees: [] + }) + await attendance.save(); + } + } + if (mainAttendances.length && !attendances.length) { + const weeks = []; + const createdAtDates = []; + for (const attendance of mainAttendances) { + weeks.push(attendance.week) + createdAtDates.push(attendance.createdAt) + } + createdAtDates.sort((a, b) => (a.getTime() - b.getTime())); + weeks.sort((a, b) => (a - b)); + + const newWeek = weeks[weeks.length - 1] + 1 + + weeks.push(newWeek) + const isInSameWeek = isSameWeek( + new Date(), + new Date(createdAtDates[createdAtDates.length - 1]), + { + weekStartsOn: 1 + } + ); + + if (!isInSameWeek) { + await Attendance.create({ + week: newWeek, + phase: phase, + cohort: team.cohort, + teams: [{ + team: team._id, + trainees: [] + }] + }) + } + } + } + } + } catch (error) { + console.error('Error in scheduled job:', error); + } +}); \ No newline at end of file diff --git a/src/utils/getDateForDays.ts b/src/utils/getDateForDays.ts new file mode 100644 index 00000000..32279ac2 --- /dev/null +++ b/src/utils/getDateForDays.ts @@ -0,0 +1,71 @@ +import { WeekdaysInterface } from '../resolvers/attendance.resolvers'; + +const handleAttendanceDay = (dayDate: string) => { + const today = new Date(); + const input = new Date(dayDate); + + today.setHours(0, 0, 0, 0); + input.setHours(0, 0, 0, 0); + + const previousDay = new Date(today); + previousDay.setDate(today.getDate() - 1); + + if (input.getTime() === today.getTime()) { + return true; + } + + if (input.getTime() === previousDay.getTime()) { + return true; + } + + // Check if today is Monday and selectedDayDate is last Friday + if (today.getDay() === 1 && input.getDay() === 5) { + const lastFriday = new Date(today); + lastFriday.setDate(today.getDate() - 3); + + return input.getTime() === lastFriday.getTime(); + } + + return false; +}; + +const days: ('mon' | 'tue' | 'wed' | 'thu' | 'fri')[] = ['mon', 'tue', 'wed', 'thu', 'fri']; + +export const getDateForDays = (inputDate: string) => { + try { + const date = new Date(Number(inputDate)); + let dayOfWeek = date.getDay(); + if (dayOfWeek === 0) { + dayOfWeek = 7 + } + const dateObj: WeekdaysInterface = { + mon: { date: '', isValid: false }, + tue: { date: '', isValid: false }, + wed: { date: '', isValid: false }, + thu: { date: '', isValid: false }, + fri: { date: '', isValid: false } + }; + + for (let i = 1; i <= 5; i++) { + const daysToAdd = i - dayOfWeek; + const weekdayDate = new Date(date); + weekdayDate.setDate(date.getDate() + daysToAdd); + + dateObj[days[i - 1]].date = weekdayDate.toISOString().split('T')[0]; + dateObj[days[i - 1]].isValid = handleAttendanceDay(weekdayDate.toISOString()) + } + + return dateObj; + } catch (error) { + return { + mon: { date: '', isValid: false }, + tue: { date: '', isValid: false }, + wed: { date: '', isValid: false }, + thu: { date: '', isValid: false }, + fri: { date: '', isValid: false } + } + } + +}; + +