Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update isUnusedAttendanceCode to allow any non-conflicting Event #426

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions migrations/0044-remove-unique-attendance-code-constraint.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await queryRunner.query(`ALTER TABLE "${TABLE_NAME}" DROP CONSTRAINT "${CONSTRAINT_NAME}"`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "${TABLE_NAME}" ADD UNIQUE ("attendanceCode")`);
}
}
2 changes: 1 addition & 1 deletion models/EventModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
53 changes: 48 additions & 5 deletions repositories/EventRepository.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -33,16 +33,59 @@ export class EventRepository extends BaseRepository<EventModel> {
}

public async findByAttendanceCode(attendanceCode: string): Promise<EventModel> {
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];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What defines the first event? Is it sorted by start time?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In theory there should never be more than one eligible event; that would mean that there are two events occurring at the same time sharing an attendance code which wouldn't make any sense. This is handled by isAvailableAttendanceCode and tested in the attached postman screenshots.

However, since the check-in period is defined as 30 minutes before and after an event, two events happening one after another with the same attendance code could cause an issue.

The solution to this could be to simply disallow this case, which involves changing isAvailableAttendanceCode to check for conflicting events in this "check-in period" rather than the duration of the event itself. Let me know your thoughts on this change!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the "check-in period" approach. However, realistically, we can allow more padding room cases like this, such as not allowing events with the same checkin code 3 days before and after the event. This would avoid the edge case of having a time precisely in the "check-in period"s of two close events with the same checkin code.

}

// 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;
Comment on lines +48 to +64
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious, what's the edge case that warrants this code? Shouldn't all potentially valid events be caught in the above code? I know we're validating it one more time in the AttendanceService, just curious what the reasoning for adding it was

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And side note, if there is a use case that warrants this code, does it make more sense to use validEvents instead of matchingEvents?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use case is the small pop-up that announces whether an event "hasn't occurred yet / has already occurred / doesn't exist" when the user enters an attendance code that isn't matched to an event currently going on, in AttendanceService's validateEventToAttend method.

The way it used to work when attendance codes were unique is that the validate method would simply check if the one event with the matching attendance code hasn't started, has already ended, or doesn't exist in the schema.

However with repeated attendance codes, it is possible for events sharing the same code to be simultaneously "too early" AND "too late," so I chose to return the closest event to the current date as a "guess" as to what the user meant to check in to when they entered a code. For example, if code "ABC" was used both for an event a year ago and two days in the future, I'm assuming that the user meant to check in to the one two days in the future, and will show the "hasn't occurred yet" message. Let me know if this implementation needs more consideration.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we shouldn't have the "hasn't occurred yet" message at all as it give away the checkin code for a future event, nullifying the secrecy of the checkin code.

}

public async deleteEvent(event: EventModel): Promise<EventModel> {
return this.repository.remove(event);
}

public async isUnusedAttendanceCode(attendanceCode: string): Promise<boolean> {
const count = await this.repository.count({ attendanceCode });
return count === 0;
public async isAvailableAttendanceCode(attendanceCode: string, start: Date, end: Date): Promise<boolean> {
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<EventModel> {
Expand Down
5 changes: 1 addition & 4 deletions services/AttendanceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
13 changes: 9 additions & 4 deletions services/EventService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ export default class EventService {
public async create(event: Event): Promise<PublicEvent> {
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));
});
Expand Down Expand Up @@ -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);
});
Expand Down
34 changes: 33 additions & 1 deletion tests/attendance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 () => {
Expand Down
141 changes: 114 additions & 27 deletions tests/event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand Down