diff --git a/.gitignore b/.gitignore index d6778586..a772488d 100755 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules # All Environment Files +*.env .env.* # Google Cloud Platform Credentials diff --git a/.husky/commit-msg b/.husky/commit-msg index c9ff16ad..72e16c61 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1 +1 @@ -npx --no -- commitlint --edit "\${1}" +npx --no -- commitlint --edit diff --git a/src/assets/email/AccountInvitation.mjml b/src/assets/email/AccountInvitation.mjml new file mode 100644 index 00000000..b4bcd59c --- /dev/null +++ b/src/assets/email/AccountInvitation.mjml @@ -0,0 +1,60 @@ + + + We've got your application! + + + + + + + + + + Hey there! 👋 + + + + You've been invited to create an account on our sponsor + dashboard. On the sponsor dashboard you can find important + information about check-in, mentoring, judging, workshops, Discord, and + the schedule for the weekend. On the sponsor dashboard you will be able + to look up hacker applications and information. Additionally, if you + have resume access you will be able to download hacker resumes through + the dashboard. + + + + Sponsor check-in starts at 6:00 pm on Saturday, January 28th on the + McHacks Discord server and opening ceremonies will begin at 7:00 pm. Be + sure to get set up on the McHacks Discord server at https://discord.gg/XVSdW9pc + and contact your coordinator to set up your server roles. + + + + Get access to the sponsor dashboard by clicking on the button below. + + + + + Create your account + + + + + If you have any questions, feel free to reach out to your coordinator. + + + + McHacks Team +
+ mchacks.ca +
+
+
+ + +
+
diff --git a/src/constants/authorization-level.constant.ts b/src/constants/authorization-level.constant.ts index 791ed425..7ac13322 100644 --- a/src/constants/authorization-level.constant.ts +++ b/src/constants/authorization-level.constant.ts @@ -1,8 +1,8 @@ export enum AuthorizationLevel { - Staff = "Staff", - Sponsor = "Sponsor", - Volunteer = "Volunteer", - Hacker = "Hacker", - Account = "Account", - None = "None", + Staff = "Staff", + Sponsor = "Sponsor", + Volunteer = "Volunteer", + Hacker = "Hacker", + Account = "Account", + None = "None" } diff --git a/src/constants/error.constant.ts b/src/constants/error.constant.ts index 89a38941..f3228b30 100644 --- a/src/constants/error.constant.ts +++ b/src/constants/error.constant.ts @@ -13,7 +13,7 @@ const SPONSOR_ID_409_MESSAGE = "Conflict with sponsor accountId link"; const VOLUNTEER_ID_409_MESSAGE = "Conflict with volunteer accountId link"; const HACKER_ID_409_MESSAGE = "Conflict with hacker accountId link"; const TEAM_MEMBER_409_MESSAGE = - "Conflict with team member being in another team"; + "Conflict with team member being in another team"; const TEAM_NAME_409_MESSAGE = "Conflict with team name already in use"; const HACKER_STATUS_409_MESSAGE = "Conflict with hacker status"; const TEAM_SIZE_409_MESSAGE = "Team full"; @@ -24,7 +24,7 @@ const VALIDATION_422_MESSAGE = "Validation failed"; const ACCOUNT_DUPLICATE_422_MESSAGE = "Account already exists"; const ROLE_DUPLICATE_422_MESSAGE = "Role already exists"; const SETTINGS_422_MESSAGE = - "openTime must be before closeTime, and closeTime must be before confirmTime"; + "openTime must be before closeTime, and closeTime must be before confirmTime"; const ACCOUNT_TOKEN_401_MESSAGE = "Invalid token for account"; const AUTH_401_MESSAGE = "Invalid Authentication"; @@ -49,46 +49,46 @@ const ROLE_CREATE_500_MESSAGE = "Error while creating role"; const TRAVEL_CREATE_500_MESSAGE = "Error while creating travel"; export { - ACCOUNT_404_MESSAGE, - HACKER_404_MESSAGE, - TEAM_404_MESSAGE, - RESUME_404_MESSAGE, - ACCOUNT_TYPE_409_MESSAGE, - ACCOUNT_EMAIL_409_MESSAGE, - SPONSOR_ID_409_MESSAGE, - VOLUNTEER_ID_409_MESSAGE, - TEAM_MEMBER_409_MESSAGE, - TEAM_MEMBER_422_MESSAGE, - VALIDATION_422_MESSAGE, - ACCOUNT_TOKEN_401_MESSAGE, - AUTH_401_MESSAGE, - AUTH_403_MESSAGE, - ACCOUNT_403_MESSAGE, - TEAM_UPDATE_500_MESSAGE, - HACKER_UPDATE_500_MESSAGE, - HACKER_ID_409_MESSAGE, - ACCOUNT_UPDATE_500_MESSAGE, - HACKER_CREATE_500_MESSAGE, - SPONSOR_404_MESSAGE, - SPONSOR_CREATE_500_MESSAGE, - TEAM_CREATE_500_MESSAGE, - VOLUNTEER_CREATE_500_MESSAGE, - EMAIL_500_MESSAGE, - GENERIC_500_MESSAGE, - ACCOUNT_DUPLICATE_422_MESSAGE, - LOGIN_500_MESSAGE, - HACKER_STATUS_409_MESSAGE, - TEAM_SIZE_409_MESSAGE, - ROLE_DUPLICATE_422_MESSAGE, - SETTINGS_422_MESSAGE, - ROLE_CREATE_500_MESSAGE, - TEAM_NAME_409_MESSAGE, - TEAM_JOIN_SAME_409_MESSAGE, - TEAM_READ_500_MESSAGE, - VOLUNTEER_404_MESSAGE, - SPONSOR_UPDATE_500_MESSAGE, - SETTINGS_404_MESSAGE, - SETTINGS_403_MESSAGE, - TRAVEL_404_MESSAGE, - TRAVEL_CREATE_500_MESSAGE, + ACCOUNT_404_MESSAGE, + HACKER_404_MESSAGE, + TEAM_404_MESSAGE, + RESUME_404_MESSAGE, + ACCOUNT_TYPE_409_MESSAGE, + ACCOUNT_EMAIL_409_MESSAGE, + SPONSOR_ID_409_MESSAGE, + VOLUNTEER_ID_409_MESSAGE, + TEAM_MEMBER_409_MESSAGE, + TEAM_MEMBER_422_MESSAGE, + VALIDATION_422_MESSAGE, + ACCOUNT_TOKEN_401_MESSAGE, + AUTH_401_MESSAGE, + AUTH_403_MESSAGE, + ACCOUNT_403_MESSAGE, + TEAM_UPDATE_500_MESSAGE, + HACKER_UPDATE_500_MESSAGE, + HACKER_ID_409_MESSAGE, + ACCOUNT_UPDATE_500_MESSAGE, + HACKER_CREATE_500_MESSAGE, + SPONSOR_404_MESSAGE, + SPONSOR_CREATE_500_MESSAGE, + TEAM_CREATE_500_MESSAGE, + VOLUNTEER_CREATE_500_MESSAGE, + EMAIL_500_MESSAGE, + GENERIC_500_MESSAGE, + ACCOUNT_DUPLICATE_422_MESSAGE, + LOGIN_500_MESSAGE, + HACKER_STATUS_409_MESSAGE, + TEAM_SIZE_409_MESSAGE, + ROLE_DUPLICATE_422_MESSAGE, + SETTINGS_422_MESSAGE, + ROLE_CREATE_500_MESSAGE, + TEAM_NAME_409_MESSAGE, + TEAM_JOIN_SAME_409_MESSAGE, + TEAM_READ_500_MESSAGE, + VOLUNTEER_404_MESSAGE, + SPONSOR_UPDATE_500_MESSAGE, + SETTINGS_404_MESSAGE, + SETTINGS_403_MESSAGE, + TRAVEL_404_MESSAGE, + TRAVEL_CREATE_500_MESSAGE }; diff --git a/src/controllers/account.controller.ts b/src/controllers/account.controller.ts index be8086f3..48d823f2 100644 --- a/src/controllers/account.controller.ts +++ b/src/controllers/account.controller.ts @@ -28,6 +28,8 @@ import { join } from "path"; import { Validator } from "@middlewares/validator.middleware"; import AccountConfirmation from "@models/account-confirmation-token.model"; import * as jwt from "jsonwebtoken"; +import { InvitationService } from "@app/services/invitation.service"; +import Invitation from "@app/models/invitation.model"; @autoInjectable() @Controller("/account") @@ -35,9 +37,79 @@ export class AccountController { constructor( private readonly accountService: AccountService, private readonly accountConfirmationService: AccountConfirmationService, + private readonly invitationService: InvitationService, private readonly mailer: EmailService ) {} + @Post("/invite", [ + EnsureAuthenticated, + EnsureAuthorization([AuthorizationLevel.Staff]) + ]) + async createWithInvite( + @Request() request: ExpressRequest, + @Response() response: ExpressResponse, + @Body("email") email: string, + @Body("accountType") accountType: string + ) { + const inviter = await this.accountService.findByIdentifier( + //@ts-ignore + request.user?.identifier + ); + + if (inviter) { + const invitation = await this.invitationService.save({ + email, + accountType, + inviter + }); + await this.mailer.send( + { + to: invitation.email, + subject: "Account Creation Instructions", + html: join( + __dirname, + "../assets/email/AccountInvitation.mjml" + ) + }, + { + link: this.invitationService.generateLink( + "account/create", + this.invitationService.generateToken(invitation), + accountType + ) + }, + (error?: any) => { + if (error) + response.status(500).send({ + message: ErrorConstants.EMAIL_500_MESSAGE, + data: error + }); + } + ); + return response.status(200).send({ + message: SuccessConstants.ACCOUNT_INVITE, + data: {} + }); + } else { + return response.status(404).json({ + message: ErrorConstants.ACCOUNT_404_MESSAGE + }); + } + } + + @Get("/invite", [ + EnsureAuthenticated, + EnsureAuthorization([AuthorizationLevel.Staff]) + ]) + async getInvited(@Response() response: ExpressResponse) { + const result = await this.invitationService.find(); + + response.status(200).json({ + message: SuccessConstants.ACCOUNT_READ, + data: result + }); + } + @Get("/", [ EnsureAuthenticated, EnsureAuthorization([ @@ -120,25 +192,24 @@ export class AccountController { async create( @Response() response: ExpressResponse, @Body() account: Account, - @Headers("X-Invite-Token") token?: string + // @Params("token") token?: string, + // @Params("accountType") accType?: string, + @Headers("token") token?: string ) { if (token) { - const data = jwt.verify( - token, - process.env.JWT_CONFIRM_ACC_SECRET! - ) as { - identifier: number; + const data = jwt.verify(token, process.env.JWT_INVITE_SECRET!) as { + invitation: Invitation; }; - const result = await this.accountConfirmationService.findByIdentifier( - data.identifier + const result = await this.invitationService.findByIdentifier( + data.email ); if (result) { account.confirmed = true; account.accountType = result.accountType; - this.accountConfirmationService.delete(result.identifier); + this.invitationService.delete(data.email); } } @@ -146,7 +217,7 @@ export class AccountController { if (result) { const model = await this.accountConfirmationService.save({ - accountType: GeneralConstants.HACKER, + accountType: result.accountType, email: result.email, confirmationType: GeneralConstants.CONFIRMATION_TYPE_ORGANIC, account: result @@ -202,6 +273,7 @@ export class AccountController { ) { //TODO - Implement resend e-mail confirmation and verification. //TODO - A thrifty user can update their password from here and it would not be hashed, we should attempt to block. + delete update.password; const result = await this.accountService.update(identifier, update); return result @@ -216,64 +288,4 @@ export class AccountController { } }); } - - @Post("/invite", [ - EnsureAuthenticated, - EnsureAuthorization([AuthorizationLevel.Staff]) - ]) - async createWithInvite( - @Response() response: ExpressResponse, - @Body("email") email: string, - @Body("accountType") accountType: string - ) { - const model = await this.accountConfirmationService.save({ - email: email, - accountType: accountType - }); - - await this.mailer.send( - { - to: model.email, - subject: "Account Confirmation Instructions", - html: join( - __dirname, - "../assets/email/AccountConfirmation.mjml" - ) - }, - { - link: this.accountConfirmationService.generateLink( - "confirm", - this.accountConfirmationService.generateToken( - model.identifier, - model.account!.identifier - ) - ) - }, - (error?: any) => { - if (error) - response.status(500).send({ - message: ErrorConstants.EMAIL_500_MESSAGE, - data: error - }); - } - ); - - return response.status(200).send({ - message: SuccessConstants.ACCOUNT_INVITE, - data: {} - }); - } - - @Get("/invites", [ - EnsureAuthenticated, - EnsureAuthorization([AuthorizationLevel.Staff]) - ]) - async getInvited(@Response() response: ExpressResponse) { - const result: Array = await this.accountConfirmationService.find(); - - response.status(200).json({ - message: SuccessConstants.ACCOUNT_READ, - data: result - }); - } } diff --git a/src/controllers/hacker.controller.ts b/src/controllers/hacker.controller.ts index 2b1da149..454c35d2 100644 --- a/src/controllers/hacker.controller.ts +++ b/src/controllers/hacker.controller.ts @@ -23,13 +23,18 @@ import { import { StorageService } from "@services/storage.service"; import { upload } from "@middlewares/multer.middleware"; import { Validator } from "@app/middlewares/validator.middleware"; +import { HackerStatus } from "@app/constants/general.constant"; +import { AccountService } from "@app/services/account.service"; +import Account from "@app/models/account.model"; +import { QueryFailedError } from "typeorm"; @autoInjectable() @Controller("/hacker") export class HackerController { constructor( private readonly hackerService: HackerService, - private readonly storageService: StorageService + private readonly storageService: StorageService, + private readonly accountService: AccountService ) {} @Get("/self", [ @@ -91,7 +96,7 @@ export class HackerController { @Body() hacker: Hacker ) { //TODO - Check if applications are open when hacker is created. - //TODO - Fix bug where Hacker status is None as it is passed into the API. (Maybe override the status variable somehow?) + hacker.status = HackerStatus.Applied; const result: Hacker = await this.hackerService.save(hacker); return result @@ -117,7 +122,9 @@ export class HackerController { @Params("identifier") identifier: number, @Body() update: Partial ) { - const result = await this.hackerService.update(identifier, update); + // Project only application to prevent overposting + const toUpdate: Partial = { application: update.application }; + const result = await this.hackerService.update(identifier, toUpdate); return result ? response.status(200).json({ @@ -189,8 +196,8 @@ export class HackerController { //TODO - Implement affectedRows > 0 success check. const result = await this.hackerService.updateApplicationField( identifier, - "general.URL.resume", - request.file + "{general,URL,resume}", + fileName ); response.status(200).send({ @@ -199,6 +206,30 @@ export class HackerController { }); } } + + @Patch("/status/:identifier", [ + EnsureAuthenticated, + EnsureAuthorization([AuthorizationLevel.Staff]) + ]) + async updateStatus( + @Params("identifier") identifier: number, + @Body("status") status: string, + @Response() response: ExpressResponse + ) { + const result = await this.hackerService.update(identifier, { status }); + + return result + ? response.status(200).json({ + message: SuccessConstants.HACKER_UPDATE, + data: result + }) + : response.status(404).json({ + message: ErrorConstants.HACKER_404_MESSAGE, + data: { + identifier: identifier + } + }); + } } //TODO - Implement statistics features, batch accept/application change features, and status change emails. diff --git a/src/controllers/search.controller.ts b/src/controllers/search.controller.ts index 1ae95300..69c847dc 100644 --- a/src/controllers/search.controller.ts +++ b/src/controllers/search.controller.ts @@ -23,9 +23,12 @@ export class SearchController { async execute( @Response() response: ExpressResponse, @Query("model") model: string, - @Body("filters") filters: Array + @Query("q") filters: string ) { - const result = await this.searchService.executeQuery(model, filters); + const result = await this.searchService.executeQuery( + model, + JSON.parse(filters) + ); response.status(200).send({ message: diff --git a/src/controllers/setting.controller.ts b/src/controllers/setting.controller.ts index 4fb9c137..4ab2664d 100644 --- a/src/controllers/setting.controller.ts +++ b/src/controllers/setting.controller.ts @@ -63,17 +63,31 @@ export class SettingController { }); } + @Patch("", [ + EnsureAuthenticated, + EnsureAuthorization([AuthorizationLevel.Staff]) + ]) + async update( + @Response() response: ExpressResponse, + @Body() settings: { [setting: string]: string | number | boolean } + ) { + await this.settingService.update(settings); + return response.status(200).json({ + message: SuccessConstants.SETTINGS_PATCH + }); + } + @Patch("/:identifier", [ EnsureAuthenticated, EnsureAuthorization([AuthorizationLevel.Staff]), Validator(Setting) ]) - async update( + async updateOne( @Response() response: ExpressResponse, @Params("identifier") identifier: number, @Body() setting: Partial ) { - const result = await this.settingService.update(identifier, setting); + const result = await this.settingService.updateOne(identifier, setting); return result ? response.status(200).json({ diff --git a/src/controllers/sponsor.controller.ts b/src/controllers/sponsor.controller.ts index 4f9a5581..8789f82e 100644 --- a/src/controllers/sponsor.controller.ts +++ b/src/controllers/sponsor.controller.ts @@ -5,6 +5,7 @@ import { Params, Patch, Post, + Request, Response } from "@decorators/express"; import { autoInjectable } from "tsyringe"; @@ -13,7 +14,10 @@ import { EnsureAuthenticated } from "@middlewares/authenticated.middleware"; import { EnsureAuthorization } from "@middlewares/authorization.middleware"; import Sponsor from "@models/sponsor.model"; import { SponsorService } from "@services/sponsor.service"; -import { Response as ExpressResponse } from "express"; +import { + Request as ExpressRequest, + Response as ExpressResponse +} from "express"; import * as SuccessConstants from "@constants/success.constant"; import * as ErrorConstants from "@constants/error.constant"; import { Validator } from "@app/middlewares/validator.middleware"; @@ -23,6 +27,33 @@ import { Validator } from "@app/middlewares/validator.middleware"; export class SponsorController { constructor(private readonly sponsorService: SponsorService) {} + @Get("/self", [ + EnsureAuthenticated, + EnsureAuthorization([ + AuthorizationLevel.Staff, + AuthorizationLevel.Sponsor + ]) + ]) + async getSelf( + @Request() request: ExpressRequest, + @Response() response: ExpressResponse + ) { + const sponsor: + | Sponsor + | undefined = await this.sponsorService.findByIdentifier( + //@ts-ignore + request.user?.identifier + ); + return sponsor + ? response.status(200).json({ + message: SuccessConstants.SPONSOR_READ, + data: sponsor + }) + : response.status(404).json({ + message: ErrorConstants.SPONSOR_404_MESSAGE + }); + } + @Get("/:identifier", [ EnsureAuthenticated, EnsureAuthorization([ diff --git a/src/middlewares/validator.middleware.ts b/src/middlewares/validator.middleware.ts index 2fc9a50b..6647f5f0 100644 --- a/src/middlewares/validator.middleware.ts +++ b/src/middlewares/validator.middleware.ts @@ -1,6 +1,6 @@ import { Middleware } from "@decorators/express"; -import { plainToClass } from "class-transformer"; -import { validateOrReject } from "class-validator"; +import { plainToInstance } from "class-transformer"; +import { validate, validateOrReject } from "class-validator"; import { Request, Response, NextFunction } from "express"; import { ParamsDictionary } from "express-serve-static-core"; import { ParsedQs } from "qs"; @@ -20,7 +20,7 @@ export function Validator(model: any): any { next: NextFunction ): Promise { await validateOrReject( - plainToClass(model, request.body), + plainToInstance(model, request.body), request.method === "PATCH" ? { skipMissingProperties: true } : {} diff --git a/src/models/account-confirmation-token.model.ts b/src/models/account-confirmation-token.model.ts index d6ca5543..a5fd29e5 100644 --- a/src/models/account-confirmation-token.model.ts +++ b/src/models/account-confirmation-token.model.ts @@ -4,14 +4,15 @@ import { Column, JoinColumn, OneToOne, - PrimaryGeneratedColumn + PrimaryGeneratedColumn, + PrimaryColumn } from "typeorm"; import Account from "@models/account.model"; import * as GeneralConstants from "@constants/general.constant"; @Entity() class AccountConfirmation { - @PrimaryGeneratedColumn() + @PrimaryColumn() readonly identifier: number; @OneToOne(() => Account) diff --git a/src/models/account.model.ts b/src/models/account.model.ts index 016e2583..1fe6e253 100644 --- a/src/models/account.model.ts +++ b/src/models/account.model.ts @@ -55,11 +55,11 @@ class Account { @IsEnum(UserType) accountType: string; - @Column("date", { nullable: false }) + @Column("timestamp", { nullable: false }) birthDate: Date; @Column() - @IsPhoneNumber() + // @IsPhoneNumber() phoneNumber: string; toJSON() { diff --git a/src/models/hacker.model.ts b/src/models/hacker.model.ts index 0de1df30..da8a7d70 100644 --- a/src/models/hacker.model.ts +++ b/src/models/hacker.model.ts @@ -1,12 +1,22 @@ import { HackerStatus } from "@constants/general.constant"; -import { Column, Entity, JoinColumn, ManyToOne, OneToOne } from "typeorm"; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + OneToOne, + PrimaryColumn +} from "typeorm"; import Account from "@models/account.model"; import { ApplicationSchema } from "@models/application.model"; import Team from "@models/team.model"; @Entity() class Hacker { - @OneToOne(() => Account, { primary: true, cascade: true }) + @PrimaryColumn() + identifier: number; + + @OneToOne(() => Account, { cascade: false }) @JoinColumn({ name: "identifier" }) account: Account; diff --git a/src/models/invitation.model.ts b/src/models/invitation.model.ts new file mode 100644 index 00000000..6f13d04b --- /dev/null +++ b/src/models/invitation.model.ts @@ -0,0 +1,22 @@ +import { IsEmail } from "class-validator"; +import { Column, Entity, ManyToOne, PrimaryColumn } from "typeorm"; +import Account from "./account.model"; +import { UserType } from "@constants/general.constant"; + +@Entity() +class Invitation { + @PrimaryColumn() + @IsEmail() + email: string; + + @Column({ + enum: UserType, + default: UserType.Hacker + }) + accountType: string; + + @ManyToOne(() => Account) + inviter: Account; +} + +export default Invitation; diff --git a/src/models/team.model.ts b/src/models/team.model.ts index fa21247d..67284aa7 100644 --- a/src/models/team.model.ts +++ b/src/models/team.model.ts @@ -26,13 +26,11 @@ class Team { @JoinColumn() members: Array; - @Column({ nullable: true }) - @IsString() - submission?: string; + @Column({ type: String, nullable: true }) + submission?: string | null; - @Column({ nullable: true }) - @IsString() - project?: string; + @Column({ type: String, nullable: true }) + project?: string | null; } export default Team; diff --git a/src/services/account-confirmation.service.ts b/src/services/account-confirmation.service.ts index ef4a1193..941ad850 100644 --- a/src/services/account-confirmation.service.ts +++ b/src/services/account-confirmation.service.ts @@ -49,7 +49,7 @@ export class AccountConfirmationService { } public generateLink(route: string, token: string): string { - return `${process.env.FRONTEND_ADDRESS_DEV}/${route}?token=${token}`; + return `${process.env.FRONTEND_ADDRESS}/account/${route}?token=${token}`; } public generateToken(identifier: number, account: number): string { diff --git a/src/services/hacker.service.ts b/src/services/hacker.service.ts index 3a9f4a86..6b952f43 100644 --- a/src/services/hacker.service.ts +++ b/src/services/hacker.service.ts @@ -43,14 +43,15 @@ export class HackerService { public async updateApplicationField( identifier: number, - key: string, + path: string, value: any ): Promise { return await this.hackerRepository .createQueryBuilder() .update() .set({ - application: () => `jsonb_set(application,${key},${value})` + application: () => + `jsonb_set(application, '${path}', '"${value}"')` }) .where("identifier = :identifier", { identifier: identifier diff --git a/src/services/invitation.service.ts b/src/services/invitation.service.ts new file mode 100644 index 00000000..f506daad --- /dev/null +++ b/src/services/invitation.service.ts @@ -0,0 +1,50 @@ +import Invitation from "@app/models/invitation.model"; +import { autoInjectable, singleton } from "tsyringe"; +import { DeleteResult, getRepository, Repository } from "typeorm"; +import jwt from "jsonwebtoken"; + +@autoInjectable() +@singleton() +export class InvitationService { + private readonly invitationRepository: Repository; + + constructor() { + this.invitationRepository = getRepository(Invitation); + } + + public async find() { + return await this.invitationRepository.find(); + } + + public async save(invitation: Invitation) { + return await this.invitationRepository.save(invitation); + } + + public async delete(email: string): Promise { + return await this.invitationRepository.delete(email); + } + + public async findByIdentifier( + email: string + ): Promise { + return await this.invitationRepository.findOne(email); + } + + public generateLink( + route: string, + token: string, + accountType: string + ): string { + return `${process.env.FRONTEND_ADDRESS}/${route}?token=${token}&accountType=${accountType}`; + } + + public generateToken(invitation: Invitation) { + return jwt.sign( + invitation, + process.env.JWT_INVITE_SECRET ?? "default", + { + expiresIn: "1 week" + } + ); + } +} diff --git a/src/services/search.service.ts b/src/services/search.service.ts index 9fc5d432..96d043a2 100644 --- a/src/services/search.service.ts +++ b/src/services/search.service.ts @@ -2,9 +2,9 @@ import { singleton } from "tsyringe"; import { Connection, getConnection } from "typeorm"; export interface Filter { - parameter: string; + param: string; operation: Operation; - value: string; + value: string | string[]; } enum Operation { @@ -23,6 +23,24 @@ export class SearchService { this.connection = getConnection(); } + public parseValueList(values: string[]) { + return "(" + values.map((value) => `'${value}'`) + ")"; + } + + public parseParam(param: string) { + const path = param.split("."); + return ( + path[0] + + "->" + + path + .slice(1, path.length - 1) + .map((s) => `'${s}'`) + .join("->") + + "->>" + + `'${path[path.length - 1]}'` + ); + } + public async executeQuery( model: string, query: Array @@ -31,34 +49,34 @@ export class SearchService { const builder = this.connection .getRepository(metadata) .createQueryBuilder(model) - .loadAllRelationIds(); + .leftJoinAndSelect("hacker.account", "account"); - query.forEach(({ parameter, operation, value }: Filter) => { - switch (operation) { + query.forEach(({ param, operation, value }: Filter) => { + param = this.parseParam(param); + switch (operation.toUpperCase()) { case Operation.Equal: case Operation.In: - builder.andWhere(`:parameter :operation :value`, { - parameter: parameter, - operation: operation, - value: value - }); + builder.andWhere( + `${param} ${operation} ${this.parseValueList( + value as string[] + )}` + ); break; case Operation.Like: - builder.andWhere(`:parameter :operation %:value%`, { - parameter: parameter, - operation: operation, - value: value + builder.andWhere(`${param} ${operation} %:value%`, { + param, + operation, + value }); break; case Operation.Limit: - builder.skip(Number.parseInt(value)); + builder.skip(Number.parseInt(value as string)); break; case Operation.Skip: - builder.skip(Number.parseInt(value)); + builder.skip(Number.parseInt(value as string)); break; } }); - return builder.getMany(); } } diff --git a/src/services/setting.service.ts b/src/services/setting.service.ts index e011aaba..718d875c 100644 --- a/src/services/setting.service.ts +++ b/src/services/setting.service.ts @@ -8,8 +8,31 @@ export class SettingService { this.settingRepository = getRepository(Setting); } - public async find(): Promise> { - return await this.settingRepository.find(); + public flattenOne(setting: Setting) { + const key = setting.key; + let value: string | number | boolean = setting.value; + if (!isNaN(+value)) { + value = +value; + } else if (value == "true") { + value = true; + } else if (value == "false") { + value = false; + } + return { [key]: value }; + } + + public flatten( + settings: Array + ): { [setting: string]: string | number | boolean } { + const flattened = {}; + settings.forEach((setting) => + Object.assign(flattened, this.flattenOne(setting)) + ); + return flattened; + } + + public async find(): Promise { + return this.flatten(await this.settingRepository.find()); } public async findByIdentifier( @@ -28,7 +51,22 @@ export class SettingService { return await this.settingRepository.save(setting); } - public async update( + public async update(settings: { + [setting: string]: string | number | boolean; + }) { + for (let [key, value] of Object.entries(settings)) { + value = value.toString(); + const setting = await this.findByKey(key); + if (setting) { + setting.value = value.toString(); + await this.settingRepository.save(setting); + } else { + await this.settingRepository.save({ key, value }); + } + } + } + + public async updateOne( identifier: number, setting: Partial ): Promise { diff --git a/src/services/storage.service.ts b/src/services/storage.service.ts index 338df912..325354c7 100644 --- a/src/services/storage.service.ts +++ b/src/services/storage.service.ts @@ -6,8 +6,8 @@ import { LoggerService } from "@services/logger.service"; @autoInjectable() export class StorageService { bucketName: string | undefined; - storage: any; - bucket: any; + storage: GStorage.Storage; + bucket: GStorage.Bucket; constructor(loggerService: LoggerService) { this.bucketName = process.env.BUCKET_NAME || ""; @@ -16,8 +16,7 @@ export class StorageService { } catch (error) { loggerService.getLogger().error(error); } - if (process.env.NODE_ENV !== "production") - this.bucket = this.storage.bucket(this.bucketName); + this.bucket = this.storage.bucket(this.bucketName); } /** @@ -50,14 +49,16 @@ export class StorageService { * @param {string} filename path to file in bucket * @returns {Promise<[Buffer]>} the file data that was returned */ - download(filename: string): Promise { + download(filename: string): Promise<[Buffer]> { const file = this.bucket.file(filename); - return new Promise((resolve, reject) => { - file.exists().then((doesExist: boolean) => { + return new Promise<[Buffer]>((resolve, reject) => { + file.exists().then((doesExist: [boolean]) => { if (doesExist) { file.download() - .then(resolve) - .catch(reject); + .then((res) => resolve(res)) + .catch(() => + reject("error occured when downloading from bucket") + ); } else { reject("file does not exist"); } @@ -77,9 +78,9 @@ export class StorageService { /** * * @param {*} filename the file that you want to check exists - * @returns {Promise<[Boolean]>} + * @returns {Promise<[boolean]>} */ - exists(filename: string): boolean { + exists(filename: string): Promise<[boolean]> { const file = this.bucket.file(filename); return file.exists(); } diff --git a/src/services/team.service.ts b/src/services/team.service.ts index ed261074..1715cc78 100644 --- a/src/services/team.service.ts +++ b/src/services/team.service.ts @@ -56,11 +56,26 @@ export class TeamService { } public async removeMember(hacker: Hacker): Promise { + // hacker.team does return a number (it's the identifier). + // However, the underlying model dictates that team attribute of Hacker should be Team. + // I'm not sure why? I didn't want to change that incase it breaked something else. + // This throws a parsing error. But it will work. + const team = await this.findByIdentifier(hacker.team); + await this.teamRepository .createQueryBuilder("team") .relation(Hacker, "team") .of(hacker) .set({ team: null }); + + // If the person we're removing is the last one left on the team. Clean up teams as well. + if (team && team.members.length == 1) { + await this.teamRepository + .createQueryBuilder("team") + .delete() + .where("name = :name", { name: team.name }) + .execute(); + } } public async update(