diff --git a/api/controllers/AdminController.ts b/api/controllers/AdminController.ts index 6ab76108..b1fb41c2 100644 --- a/api/controllers/AdminController.ts +++ b/api/controllers/AdminController.ts @@ -12,7 +12,7 @@ import { CreateMilestoneResponse, CreateBonusResponse, UploadBannerResponse, - GetAllEmailsResponse, + GetAllNamesAndEmailsResponse, SubmitAttendanceForUsersResponse, ModifyUserAccessLevelResponse, GetAllUserAccessLevelsResponse, @@ -41,10 +41,10 @@ export class AdminController { } @Get('/email') - async getAllEmails(@AuthenticatedUser() user: UserModel): Promise { + async getAllNamesAndEmails(@AuthenticatedUser() user: UserModel): Promise { if (!PermissionsService.canSeeAllUserEmails(user)) throw new ForbiddenError(); - const emails = await this.userAccountService.getAllEmails(); - return { error: null, emails }; + const namesAndEmails = await this.userAccountService.getAllNamesAndEmails(); + return { error: null, namesAndEmails }; } @Post('/milestone') diff --git a/api/validators/EventControllerRequests.ts b/api/validators/EventControllerRequests.ts index 64e7a57d..1a8a1306 100644 --- a/api/validators/EventControllerRequests.ts +++ b/api/validators/EventControllerRequests.ts @@ -7,6 +7,7 @@ import { PatchEventRequest as IPatchEventRequest, SubmitEventFeedbackRequest as ISubmitEventFeedbackRequest, Event as IEvent, + Uuid, } from '../../types'; import { IsValidEventFeedback } from '../decorators/Validators'; @@ -28,6 +29,12 @@ export class OptionalEventProperties implements IOptionalEventProperties { @Allow() staffPointBonus?: number; + + @Allow() + discordEvent?: Uuid; + + @Allow() + googleCalendarEvent?: Uuid; } export class Event extends OptionalEventProperties implements IEvent { diff --git a/migrations/0044-add-discord-and-google-calendar-event-columns.ts b/migrations/0044-add-discord-and-google-calendar-event-columns.ts new file mode 100644 index 00000000..3cb009c4 --- /dev/null +++ b/migrations/0044-add-discord-and-google-calendar-event-columns.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +const TABLE_NAME = 'Events'; + +export class AddDiscordAndGoogleCalendarEventColumns1712185658430 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumns(TABLE_NAME, [ + new TableColumn({ + name: 'discordEvent', + type: 'uuid', + isNullable: true, + }), + new TableColumn({ + name: 'googleCalendarEvent', + type: 'uuid', + isNullable: true, + }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumns(TABLE_NAME, [ + new TableColumn({ + name: 'discordEvent', + type: 'uuid', + }), + new TableColumn({ + name: 'googleCalendarEvent', + type: 'uuid', + }), + ]); + } +} diff --git a/migrations/0045-fix-discord-gcal-field.ts b/migrations/0045-fix-discord-gcal-field.ts new file mode 100644 index 00000000..5cb62f6d --- /dev/null +++ b/migrations/0045-fix-discord-gcal-field.ts @@ -0,0 +1,12 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class FixDiscordGcalField1714770061929 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "Events" ALTER COLUMN "discordEvent" TYPE varchar'); + await queryRunner.query('ALTER TABLE "Events" ALTER COLUMN "googleCalendarEvent" TYPE varchar'); + } + + public async down(queryRunner: QueryRunner): Promise { + // nothing here because it's fixing an earlier migration (# 0044) + } +} diff --git a/models/EventModel.ts b/models/EventModel.ts index e4a5f40f..256fa443 100644 --- a/models/EventModel.ts +++ b/models/EventModel.ts @@ -67,6 +67,12 @@ export class EventModel extends BaseEntity { @OneToMany((type) => ExpressCheckinModel, (expressCheckin) => expressCheckin.event, { cascade: true }) expressCheckins: ExpressCheckinModel[]; + @Column('varchar', { nullable: true }) + discordEvent: Uuid; + + @Column('varchar', { nullable: true }) + googleCalendarEvent: Uuid; + public getPublicEvent(canSeeAttendanceCode = false): PublicEvent { const publicEvent: PublicEvent = { uuid: this.uuid, @@ -83,6 +89,8 @@ export class EventModel extends BaseEntity { pointValue: this.pointValue, requiresStaff: this.requiresStaff, staffPointBonus: this.staffPointBonus, + discordEvent: this.discordEvent, + googleCalendarEvent: this.googleCalendarEvent, }; if (canSeeAttendanceCode) publicEvent.attendanceCode = this.attendanceCode; return publicEvent; diff --git a/package.json b/package.json index e854d21b..79b11202 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@acmucsd/membership-portal", - "version": "3.5.1", + "version": "3.6.1", "description": "REST API for ACM UCSD's membership portal.", "main": "index.d.ts", "files": [ diff --git a/repositories/UserRepository.ts b/repositories/UserRepository.ts index 95eb5934..b113d97b 100644 --- a/repositories/UserRepository.ts +++ b/repositories/UserRepository.ts @@ -2,7 +2,7 @@ import { EntityRepository, In } from 'typeorm'; import * as bcrypt from 'bcrypt'; import { Activity } from '../types/internal'; import { UserModel } from '../models/UserModel'; -import { Uuid } from '../types'; +import { Uuid, NameAndEmail } from '../types'; import { BaseRepository } from './BaseRepository'; @EntityRepository(UserModel) @@ -50,12 +50,16 @@ export class UserRepository extends BaseRepository { return this.repository.findOne({ accessCode }); } - public async getAllEmails(): Promise { - const emailsRaw = await this.repository + public async getAllNamesAndEmails(): Promise { + const namesAndEmailsRaw = await this.repository .createQueryBuilder() - .select('email') + .select(['email', 'UserModel.firstName', 'UserModel.lastName']) .getRawMany(); - return emailsRaw.map((emailRaw) => emailRaw.email); + const namesAndEmailsFormatted: NameAndEmail[] = namesAndEmailsRaw.map((nameAndEmailRaw) => ({ firstName: + nameAndEmailRaw.UserModel_firstName, + lastName: nameAndEmailRaw.UserModel_lastName, + email: nameAndEmailRaw.email })); + return namesAndEmailsFormatted; } public static async generateHash(pass: string): Promise { diff --git a/services/EmailService.ts b/services/EmailService.ts index ac99929c..70e1646a 100644 --- a/services/EmailService.ts +++ b/services/EmailService.ts @@ -86,7 +86,7 @@ export default class EmailService { order, orderItems: ejs.render(EmailService.itemDisplayTemplate, { items: order.items, totalCost: order.totalCost }), pickupEvent: order.pickupEvent, - link: `${Config.client}/store/order/${order.uuid}`, + link: `${Config.client}/store/orders`, }), }; await this.sendEmail(data); @@ -141,7 +141,7 @@ export default class EmailService { firstName, order, orderItems: ejs.render(EmailService.itemDisplayTemplate, { items: order.items, totalCost: order.totalCost }), - link: `${Config.client}/store/order/${order.uuid}`, + link: `${Config.client}/store/orders`, }), }; await this.sendEmail(data); @@ -160,7 +160,7 @@ export default class EmailService { firstName, order, orderItems: ejs.render(EmailService.itemDisplayTemplate, { items: order.items, totalCost: order.totalCost }), - link: `${Config.client}/store/order/${order.uuid}`, + link: `${Config.client}/store/orders`, }), }; await this.sendEmail(data); @@ -179,7 +179,7 @@ export default class EmailService { firstName, order, orderItems: ejs.render(EmailService.itemDisplayTemplate, { items: order.items, totalCost: order.totalCost }), - link: `${Config.client}/store/order/${order.uuid}`, + link: `${Config.client}/store/orders`, }), }; await this.sendEmail(data); @@ -219,7 +219,7 @@ export default class EmailService { unfulfilledItems: ejs.render(EmailService.itemDisplayTemplate, { items: unfulfilledItems }), fulfilledItems: ejs.render(EmailService.itemDisplayTemplate, { items: fulfilledItems }), pickupEvent, - link: `${Config.client}/store/order/${orderUuid}`, + link: `${Config.client}/store/orders`, }), }; await this.sendEmail(data); diff --git a/services/UserAccountService.ts b/services/UserAccountService.ts index d59e2d96..ab0dc926 100644 --- a/services/UserAccountService.ts +++ b/services/UserAccountService.ts @@ -20,6 +20,7 @@ import { UserPatches, UserState, PrivateProfile, + NameAndEmail, } from '../types'; import { UserRepository } from '../repositories/UserRepository'; import { UserModel } from '../models/UserModel'; @@ -181,10 +182,10 @@ export default class UserAccountService { }); } - public async getAllEmails(): Promise { + public async getAllNamesAndEmails(): Promise { return this.transactions.readOnly(async (txn) => Repositories .user(txn) - .getAllEmails()); + .getAllNamesAndEmails()); } /** diff --git a/tests/admin.test.ts b/tests/admin.test.ts index 8c697a58..dc1ff81b 100644 --- a/tests/admin.test.ts +++ b/tests/admin.test.ts @@ -1,6 +1,6 @@ import { BadRequestError, ForbiddenError } from 'routing-controllers'; import { In } from 'typeorm'; -import { ActivityScope, ActivityType, SubmitAttendanceForUsersRequest, UserAccessType } from '../types'; +import { ActivityScope, ActivityType, SubmitAttendanceForUsersRequest, UserAccessType, NameAndEmail } from '../types'; import { ControllerFactory } from './controllers'; import { DatabaseConnection, EventFactory, PortalState, UserFactory } from './data'; import { UserModel } from '../models/UserModel'; @@ -131,19 +131,24 @@ describe('retroactive attendance submission', () => { }); }); -describe('email retrieval', () => { +describe('names and emails retrieval', () => { test('gets all the emails of stored users', async () => { const conn = await DatabaseConnection.get(); const users = UserFactory.create(5); - const emails = users.map((user) => user.email.toLowerCase()); + const namesAndEmails: NameAndEmail[] = users.map((user) => ({ firstName: user.firstName, + lastName: user.lastName, + email: + user.email.toLowerCase() })); const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); await new PortalState() .createUsers(...users, admin) .write(); - const response = await ControllerFactory.admin(conn).getAllEmails(admin); - expect(expect.arrayContaining(response.emails)).toEqual([...emails, admin.email]); + const response = await ControllerFactory.admin(conn).getAllNamesAndEmails(admin); + const expected: NameAndEmail = { firstName: admin.firstName, lastName: admin.lastName, email: admin.email }; + expect(expect.arrayContaining(response.namesAndEmails)).toEqual([...namesAndEmails, + expected]); }); }); diff --git a/tests/data/EventFactory.ts b/tests/data/EventFactory.ts index d07f523b..bf6a9d38 100644 --- a/tests/data/EventFactory.ts +++ b/tests/data/EventFactory.ts @@ -41,6 +41,8 @@ export class EventFactory { deleted: false, eventLink: faker.internet.url(), thumbnail: FactoryUtils.getRandomImageUrl(), + discordEvent: faker.datatype.hexaDecimal(10), + googleCalendarEvent: faker.datatype.hexaDecimal(10), }); return EventModel.merge(fake, substitute); } diff --git a/types/ApiRequests.ts b/types/ApiRequests.ts index 87a6b6dc..816b59f7 100644 --- a/types/ApiRequests.ts +++ b/types/ApiRequests.ts @@ -155,6 +155,8 @@ export interface OptionalEventProperties { eventLink?: string; requiresStaff?: boolean; staffPointBonus?: number; + discordEvent?: Uuid; + googleCalendarEvent?: Uuid; } export interface Event extends OptionalEventProperties { diff --git a/types/ApiResponses.ts b/types/ApiResponses.ts index 4bc289d6..d3dfa353 100644 --- a/types/ApiResponses.ts +++ b/types/ApiResponses.ts @@ -6,7 +6,6 @@ import { import { MerchItemOptionMetadata, Uuid } from '.'; // RESPONSE TYPES - export interface CustomErrorBody { name: string; message: string; @@ -31,8 +30,8 @@ export interface UploadBannerResponse extends ApiResponse { banner: string; } -export interface GetAllEmailsResponse extends ApiResponse { - emails: string[]; +export interface GetAllNamesAndEmailsResponse extends ApiResponse { + namesAndEmails: NameAndEmail[]; } export interface SubmitAttendanceForUsersResponse extends ApiResponse { @@ -117,6 +116,8 @@ export interface PublicEvent { pointValue: number; requiresStaff: boolean; staffPointBonus: number; + discordEvent: Uuid; + googleCalendarEvent: Uuid; } export interface GetPastEventsResponse extends ApiResponse { @@ -323,6 +324,11 @@ export interface FulfillMerchOrderResponse extends ApiResponse { } // USER +export interface NameAndEmail { + firstName: string; + lastName: string; + email: string; +} export interface PublicActivity { type: ActivityType,