diff --git a/migrations/0044-remove-unique-attendance-code-constraint.ts b/migrations/0044-remove-unique-attendance-code-constraint.ts new file mode 100644 index 00000000..7403ffa1 --- /dev/null +++ b/migrations/0044-remove-unique-attendance-code-constraint.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const TABLE_NAME = 'Events'; +const CONSTRAINT_NAME = 'Events_attendanceCode_key'; + +export class RemoveUniqueAttendanceCodeConstraint1712188218208 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "${TABLE_NAME}" DROP CONSTRAINT "${CONSTRAINT_NAME}"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "${TABLE_NAME}" ADD UNIQUE ("attendanceCode")`); + } +} diff --git a/models/EventModel.ts b/models/EventModel.ts index e4a5f40f..0d67476c 100644 --- a/models/EventModel.ts +++ b/models/EventModel.ts @@ -43,7 +43,7 @@ export class EventModel extends BaseEntity { end: Date; @Column('varchar', { length: 255 }) - @Index({ unique: true }) + @Index({ unique: false }) attendanceCode: string; @Column('integer') diff --git a/package.json b/package.json index e854d21b..711d181e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@acmucsd/membership-portal", - "version": "3.5.1", + "version": "3.5.2", "description": "REST API for ACM UCSD's membership portal.", "main": "index.d.ts", "files": [ diff --git a/repositories/EventRepository.ts b/repositories/EventRepository.ts index 70b48810..e69cc3fc 100644 --- a/repositories/EventRepository.ts +++ b/repositories/EventRepository.ts @@ -1,4 +1,4 @@ -import { EntityRepository, SelectQueryBuilder } from 'typeorm'; +import { EntityRepository, LessThanOrEqual, MoreThanOrEqual, SelectQueryBuilder } from 'typeorm'; import { EventSearchOptions, Uuid } from '../types'; import { EventModel } from '../models/EventModel'; import { BaseRepository } from './BaseRepository'; @@ -33,16 +33,59 @@ export class EventRepository extends BaseRepository { } public async findByAttendanceCode(attendanceCode: string): Promise { - return this.repository.findOne({ attendanceCode }); + // Find all events with the given attendance code + const matchingEvents = await this.repository.find({ attendanceCode }); + + // Find all events that are currently + const validEvents = matchingEvents.filter((event) => !event.isTooEarlyToAttendEvent() + && !event.isTooLateToAttendEvent()); + + // If there are eligible events, return the first one + if (validEvents.length > 0) { + return validEvents[0]; + } + + // Otherwise, find the closest event to the current time + const currentTime = new Date(); + let closestEvent = null; + let closestTimeDifference = Infinity; + + matchingEvents.forEach((event) => { + const eventStartTime = new Date(event.start); + const timeDifference = Math.abs(eventStartTime.getTime() - currentTime.getTime()); + + // Update closest event if necessary + if (timeDifference < closestTimeDifference) { + closestEvent = event; + closestTimeDifference = timeDifference; + } + }); + + return closestEvent; } public async deleteEvent(event: EventModel): Promise { return this.repository.remove(event); } - public async isUnusedAttendanceCode(attendanceCode: string): Promise { - const count = await this.repository.count({ attendanceCode }); - return count === 0; + public async isAvailableAttendanceCode(attendanceCode: string, start: Date, end: Date): Promise { + const bufferedStart = new Date(start); + bufferedStart.setDate(bufferedStart.getDate() - 3); + + const bufferedEnd = new Date(end); + bufferedEnd.setDate(bufferedEnd.getDate() + 3); + + const hasOverlap = await this.repository.find({ + where: [ + { + attendanceCode, + start: LessThanOrEqual(bufferedEnd), + end: MoreThanOrEqual(bufferedStart), + }, + ], + }); + + return hasOverlap.length === 0; } private getBaseEventSearchQuery(options: EventSearchOptions): SelectQueryBuilder { diff --git a/services/AttendanceService.ts b/services/AttendanceService.ts index 166ef16f..ffdc21a6 100644 --- a/services/AttendanceService.ts +++ b/services/AttendanceService.ts @@ -59,10 +59,7 @@ export default class AttendanceService { } private validateEventToAttend(event: EventModel) { - if (!event) throw new NotFoundError('Oh no! That code didn\'t work.'); - if (event.isTooEarlyToAttendEvent()) { - throw new UserError('This event hasn\'t started yet, please wait to check in.'); - } + if (!event || event.isTooEarlyToAttendEvent()) throw new NotFoundError('Oh no! That code didn\'t work.'); if (event.isTooLateToAttendEvent()) { throw new UserError('This event has ended and is no longer accepting attendances'); } diff --git a/services/EventService.ts b/services/EventService.ts index 9994e080..b8455c95 100644 --- a/services/EventService.ts +++ b/services/EventService.ts @@ -24,8 +24,9 @@ export default class EventService { public async create(event: Event): Promise { const eventCreated = await this.transactions.readWrite(async (txn) => { const eventRepository = Repositories.event(txn); - const isUnusedAttendanceCode = await eventRepository.isUnusedAttendanceCode(event.attendanceCode); - if (!isUnusedAttendanceCode) throw new UserError('Attendance code has already been used'); + const isAvailableAttendanceCode = await eventRepository.isAvailableAttendanceCode(event.attendanceCode, + event.start, event.end); + if (!isAvailableAttendanceCode) throw new UserError('There is a conflicting event with the same attendance code'); if (event.start > event.end) throw new UserError('Start date after end date'); return eventRepository.upsertEvent(EventModel.create(event)); }); @@ -68,8 +69,12 @@ export default class EventService { const currentEvent = await eventRepository.findByUuid(uuid); if (!currentEvent) throw new NotFoundError('Event not found'); if (changes.attendanceCode !== currentEvent.attendanceCode) { - const isUnusedAttendanceCode = await eventRepository.isUnusedAttendanceCode(changes.attendanceCode); - if (!isUnusedAttendanceCode) throw new UserError('Attendance code has already been used'); + const isAvailableAttendanceCode = await eventRepository.isAvailableAttendanceCode( + changes.attendanceCode, changes.start, changes.end, + ); + if (!isAvailableAttendanceCode) { + throw new UserError('There is a conflicting event with the same attendance code'); + } } return eventRepository.upsertEvent(currentEvent, changes); }); diff --git a/tests/attendance.test.ts b/tests/attendance.test.ts index 62b1b55a..9335ce68 100644 --- a/tests/attendance.test.ts +++ b/tests/attendance.test.ts @@ -137,6 +137,38 @@ describe('attendance', () => { expect(attendance.event.uuid).toEqual(event.uuid); }); + test('if there are two events with same attendance code, the user attends the right one', async () => { + const conn = await DatabaseConnection.get(); + const member = UserFactory.fake(); + const attendanceCode = 'samecode'; + const event1 = EventFactory.fake({ + attendanceCode, + start: moment().subtract(20, 'minutes').toDate(), + end: moment().add(2, 'hours').add(20, 'minutes').toDate(), + }); + const event2 = EventFactory.fake({ + attendanceCode, + start: moment().add(10, 'hours').toDate(), + end: moment().add(12, 'hours').add(20, 'minutes').toDate(), + }); + + await new PortalState() + .createUsers(member) + .createEvents(event1, event2) + .write(); + + // attend event + const attendanceController = ControllerFactory.attendance(conn); + const attendEventRequest = { attendanceCode }; + await attendanceController.attendEvent(attendEventRequest, member); + + // check attendances for user (event1 should be the attended one) + const getAttendancesForUserResponse = await attendanceController.getAttendancesForCurrentUser(member); + const attendance = getAttendancesForUserResponse.attendances[0]; + expect(attendance.user.uuid).toEqual(member.uuid); + expect(attendance.event.uuid).toEqual(event1.uuid); + }); + test('throws if invalid attendance code', async () => { const conn = await DatabaseConnection.get(); const member = UserFactory.fake(); @@ -164,7 +196,7 @@ describe('attendance', () => { const attendEventRequest = { attendanceCode: event.attendanceCode }; await expect(ControllerFactory.attendance(conn).attendEvent(attendEventRequest, member)) - .rejects.toThrow('This event hasn\'t started yet, please wait to check in.'); + .rejects.toThrow('Oh no! That code didn\'t work.'); }); test('throws if attendance code entered more than 30 minutes after event', async () => { diff --git a/tests/event.test.ts b/tests/event.test.ts index ef1d64dd..d6660df3 100644 --- a/tests/event.test.ts +++ b/tests/event.test.ts @@ -30,17 +30,10 @@ describe('event creation', () => { .createUsers(admin, user) .write(); - const event = { - cover: 'https://www.google.com', - title: 'ACM Party @ RIMAC', - description: 'Indoor Pool Party', - location: 'RIMAC', - committee: 'ACM', + const event = EventFactory.fake({ start: moment().subtract(2, 'hour').toDate(), end: moment().subtract(1, 'hour').toDate(), - attendanceCode: 'p4rty', - pointValue: 10, - }; + }); const createEventRequest: CreateEventRequest = { event, @@ -74,17 +67,10 @@ describe('event creation', () => { .createUsers(user) .write(); - const event = { - cover: 'https://www.google.com', - title: 'ACM Party @ RIMAC', - description: 'Indoor Pool Party', - location: 'RIMAC', - committee: 'ACM', + const event = EventFactory.fake({ start: moment().subtract(2, 'hour').toDate(), end: moment().subtract(1, 'hour').toDate(), - attendanceCode: 'p4rty', - pointValue: 10, - }; + }); const createEventRequest: CreateEventRequest = { event, @@ -105,17 +91,10 @@ describe('event creation', () => { .createUsers(admin) .write(); - const event = { - cover: 'https://www.google.com', - title: 'ACM Party @ RIMAC', - description: 'Indoor Pool Party', - location: 'RIMAC', - committee: 'ACM', + const event = EventFactory.fake({ start: moment().subtract(1, 'hour').toDate(), end: moment().subtract(2, 'hour').toDate(), - attendanceCode: 'p4rty', - pointValue: 10, - }; + }); const createEventRequest: CreateEventRequest = { event, @@ -126,8 +105,116 @@ describe('event creation', () => { await expect(eventController.createEvent(createEventRequest, admin)) .rejects.toThrow('Start date after end date'); }); + + test('test non-conflicting event creation with re-used past attendance code', async () => { + const conn = await DatabaseConnection.get(); + const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); + const user = UserFactory.fake(); + + await new PortalState() + .createUsers(admin, user) + .write(); + + const event = EventFactory.fake({ + start: new Date('2050-08-20T10:00:00.000Z'), + end: new Date('2050-08-20T12:00:00.000Z'), + }); + + const createEventRequest: CreateEventRequest = { + event, + }; + + const eventController = ControllerFactory.event(conn); + const eventResponse = await eventController.createEvent(createEventRequest, admin); + + expect(eventResponse.event.cover).toEqual(event.cover); + expect(eventResponse.event.title).toEqual(event.title); + expect(eventResponse.event.location).toEqual(event.location); + expect(eventResponse.event.committee).toEqual(event.committee); + expect(eventResponse.event.title).toEqual(event.title); + expect(eventResponse.event.start).toEqual(event.start); + expect(eventResponse.event.end).toEqual(event.end); + expect(eventResponse.event.pointValue).toEqual(event.pointValue); + + const lookupEvent = await eventController.getOneEvent({ uuid: eventResponse.event.uuid }, user); + expect(lookupEvent.error).toEqual(null); + expect(JSON.stringify(lookupEvent.event)).toEqual(JSON.stringify(eventResponse.event)); + }); + + test('test conflicting event creation with re-used attendance code', async () => { + const conn = await DatabaseConnection.get(); + const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); + const user = UserFactory.fake(); + + await new PortalState() + .createUsers(admin, user) + .write(); + + let event = EventFactory.fake({ + start: new Date('2050-08-20T10:00:00.000Z'), + end: new Date('2050-08-20T12:00:00.000Z'), + attendanceCode: 'repeated', + }); + + const createEventRequest: CreateEventRequest = { + event, + }; + + const eventController = ControllerFactory.event(conn); + await eventController.createEvent(createEventRequest, admin); + + event = EventFactory.fake({ + start: new Date('2050-08-20T09:00:00.000Z'), + end: new Date('2050-08-20T10:30:00.000Z'), + attendanceCode: 'repeated', + }); + + const createEventRequest2: CreateEventRequest = { + event, + }; + + await expect(eventController.createEvent(createEventRequest2, admin)) + .rejects.toThrow('There is a conflicting event with the same attendance code'); + }); }); +test('test conflicting event creation with re-used attendance code - 3 days after', async () => { + const conn = await DatabaseConnection.get(); + const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); + const user = UserFactory.fake(); + + await new PortalState() + .createUsers(admin, user) + .write(); + + let event = EventFactory.fake({ + start: new Date('2050-08-20T10:00:00.000Z'), + end: new Date('2050-08-20T12:00:00.000Z'), + attendanceCode: 'repeated', + }); + + const createEventRequest: CreateEventRequest = { + event, + }; + + const eventController = ControllerFactory.event(conn); + await eventController.createEvent(createEventRequest, admin); + + event = EventFactory.fake({ + start: new Date('2050-08-20T09:00:00.000Z'), + end: new Date('2050-08-22T10:30:00.000Z'), + attendanceCode: 'repeated', + }); + + const createEventRequest2: CreateEventRequest = { + event, + }; + + await expect(eventController.createEvent(createEventRequest2, admin)) + .rejects.toThrow('There is a conflicting event with the same attendance code'); +}); + + describe('event deletion', () => { test('should delete event that has no attendances', async () => { // setting up inputs