From 50ac20e17e2ef2e271086718db5dd8b52d670a97 Mon Sep 17 00:00:00 2001 From: piersolh <145268163+piersolh@users.noreply.github.com> Date: Sun, 14 Sep 2025 14:37:41 -0400 Subject: [PATCH 1/8] adding migrations --- apps/backend/src/app.module.ts | 3 +- apps/backend/src/data-source.ts | 3 +- .../src/migrations/1754254886189-add_task.ts | 19 ---- .../src/migrations/1754254886189-init.ts | 92 +++++++++++++++++++ example.env | 5 - 5 files changed, 94 insertions(+), 28 deletions(-) delete mode 100644 apps/backend/src/migrations/1754254886189-add_task.ts create mode 100644 apps/backend/src/migrations/1754254886189-init.ts delete mode 100644 example.env diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 54fb044e..9af0cf4b 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -3,11 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AppController } from './app.controller'; import { AppService } from './app.service'; -import { TaskModule } from './task/task.module'; import AppDataSource from './data-source'; @Module({ - imports: [TypeOrmModule.forRoot(AppDataSource.options), TaskModule], + imports: [TypeOrmModule.forRoot(AppDataSource.options)], controllers: [AppController], providers: [AppService], }) diff --git a/apps/backend/src/data-source.ts b/apps/backend/src/data-source.ts index 4cd06624..e9c75481 100644 --- a/apps/backend/src/data-source.ts +++ b/apps/backend/src/data-source.ts @@ -1,6 +1,5 @@ import { DataSource } from 'typeorm'; import { PluralNamingStrategy } from './strategies/plural-naming.strategy'; -import { Task } from './task/types/task.entity'; import * as dotenv from 'dotenv'; dotenv.config(); @@ -12,7 +11,7 @@ const AppDataSource = new DataSource({ username: process.env.NX_DB_USERNAME, password: process.env.NX_DB_PASSWORD, database: process.env.NX_DB_DATABASE, - entities: [Task], + entities: [], migrations: ['apps/backend/src/migrations/*.js'], // Setting synchronize: true shouldn't be used in production - otherwise you can lose production data synchronize: false, diff --git a/apps/backend/src/migrations/1754254886189-add_task.ts b/apps/backend/src/migrations/1754254886189-add_task.ts deleted file mode 100644 index 450a6415..00000000 --- a/apps/backend/src/migrations/1754254886189-add_task.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class AddTask1754254886189 implements MigrationInterface { - name = 'AddTask1754254886189'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `CREATE TYPE "public"."tasks_category_enum" AS ENUM('Draft', 'To Do', 'In Progress', 'Completed')`, - ); - await queryRunner.query( - `CREATE TABLE "task" ("id" SERIAL NOT NULL, "title" character varying NOT NULL, "description" character varying, "dateCreated" TIMESTAMP NOT NULL DEFAULT now(), "dueDate" TIMESTAMP, "labels" jsonb NOT NULL DEFAULT '[]', "category" "public"."tasks_category_enum" NOT NULL DEFAULT 'Draft', CONSTRAINT "PK_8d12ff38fcc62aaba2cab748772" PRIMARY KEY ("id"))`, - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP TABLE "task"`); - await queryRunner.query(`DROP TYPE "public"."tasks_category_enum"`); - } -} diff --git a/apps/backend/src/migrations/1754254886189-init.ts b/apps/backend/src/migrations/1754254886189-init.ts new file mode 100644 index 00000000..d35b9233 --- /dev/null +++ b/apps/backend/src/migrations/1754254886189-init.ts @@ -0,0 +1,92 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class Init1754254886189 implements MigrationInterface { + name = 'Init1754254886189'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."commit_length_enum" AS ENUM('Semester', 'Month', 'Year')`, + ); + + await queryRunner.query( + `CREATE TYPE "public"."site_enum" AS ENUM('Downtown Campus', 'North Campus', 'West Campus', 'East Campus')`, + ); + + await queryRunner.query( + `CREATE TYPE "public"."app_status_enum" AS ENUM('App submitted', 'in review', 'forms sent', 'accepted', 'rejected')`, + ); + + await queryRunner.query( + `CREATE TYPE "public"."school_enum" AS ENUM('Harvard Medical School', 'Johns Hopkins', 'Stanford Medicine', 'Mayo Clinic', 'Other')`, + ); + + await queryRunner.query( + `CREATE TYPE "public"."experience_type_enum" AS ENUM('BS', 'MS', 'PhD', 'MD', 'MD PhD', 'RN', 'NP', 'PA', 'Other')`, + ); + + await queryRunner.query( + `CREATE TYPE "public"."interest_area_enum" AS ENUM('Nursing', 'HarmReduction', 'WomensHealth')`, + ); + + await queryRunner.query( + `CREATE TABLE "admin" ( + "id" SERIAL NOT NULL, + "name" character varying NOT NULL, + "email" character varying NOT NULL UNIQUE, + CONSTRAINT "PK_admin_id" PRIMARY KEY ("id") + )`, + ); + + await queryRunner.query( + `CREATE TABLE "discipline" ( + "id" SERIAL NOT NULL, + "name" character varying NOT NULL, + "admin_ids" integer[] NOT NULL DEFAULT '{}', + CONSTRAINT "PK_discipline_id" PRIMARY KEY ("id") + )`, + ); + + await queryRunner.query( + `CREATE TABLE "application" ( + "appId" SERIAL NOT NULL, + "phone" character varying NOT NULL, + "school" "public"."school_enum" NOT NULL, + "daysAvailable" character varying NOT NULL, + "weeklyHours" integer NOT NULL, + "experienceType" "public"."experience_type_enum" NOT NULL, + "interest" "public"."interest_area_enum" NOT NULL, + "license" character varying, + "appStatus" "public"."app_status_enum" NOT NULL DEFAULT 'App submitted', + "isInternational" boolean NOT NULL DEFAULT false, + "isLearner" boolean NOT NULL DEFAULT false, + "referredEmail" character varying, + "referred" boolean NOT NULL DEFAULT false, + CONSTRAINT "PK_application_appId" PRIMARY KEY ("appId") + )`, + ); + + await queryRunner.query( + `CREATE TABLE "learner" ( + "id" SERIAL NOT NULL, + "app_id" integer NOT NULL, + "name" character varying NOT NULL, + "startDate" DATE NOT NULL, + "endDate" DATE NOT NULL, + CONSTRAINT "PK_learner_id" PRIMARY KEY ("id"), + CONSTRAINT "FK_learner_app_id" FOREIGN KEY ("app_id") REFERENCES "application"("appId") ON DELETE CASCADE + )`, + ); + } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "learner"`); + await queryRunner.query(`DROP TABLE "application"`); + await queryRunner.query(`DROP TABLE "discipline"`); + await queryRunner.query(`DROP TABLE "admin"`); + await queryRunner.query(`DROP TYPE "public"."interest_area_enum"`); + await queryRunner.query(`DROP TYPE "public"."experience_type_enum"`); + await queryRunner.query(`DROP TYPE "public"."school_enum"`); + await queryRunner.query(`DROP TYPE "public"."app_status_enum"`); + await queryRunner.query(`DROP TYPE "public"."site_enum"`); + await queryRunner.query(`DROP TYPE "public"."commit_length_enum"`); + } +} diff --git a/example.env b/example.env deleted file mode 100644 index 211b1472..00000000 --- a/example.env +++ /dev/null @@ -1,5 +0,0 @@ -NX_DB_HOST=localhost, -NX_DB_USERNAME=postgres, -NX_DB_PASSWORD=, -NX_DB_DATABASE=jumpstart, -NX_DB_PORT=5432, \ No newline at end of file From 749cdabbcaef07b4471ab3dbbebe2c8ff68dead0 Mon Sep 17 00:00:00 2001 From: piersolh <145268163+piersolh@users.noreply.github.com> Date: Sun, 14 Sep 2025 14:46:57 -0400 Subject: [PATCH 2/8] adding back example env --- example.env | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 example.env diff --git a/example.env b/example.env new file mode 100644 index 00000000..a4a120c7 --- /dev/null +++ b/example.env @@ -0,0 +1,5 @@ +NX_DB_HOST=localhost +NX_DB_PORT=5432 +NX_DB_USERNAME=postgres +NX_DB_PASSWORD= +NX_DB_DATABASE=bhchp From f3d586e52b232c2b2ad779b23124ac7646c8bdf9 Mon Sep 17 00:00:00 2001 From: piersolh <145268163+piersolh@users.noreply.github.com> Date: Tue, 16 Sep 2025 22:05:30 -0400 Subject: [PATCH 3/8] adding comments to explain type error --- apps/backend/src/data-source.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/backend/src/data-source.ts b/apps/backend/src/data-source.ts index e9c75481..dee1429a 100644 --- a/apps/backend/src/data-source.ts +++ b/apps/backend/src/data-source.ts @@ -13,6 +13,8 @@ const AppDataSource = new DataSource({ database: process.env.NX_DB_DATABASE, entities: [], migrations: ['apps/backend/src/migrations/*.js'], + // migrations: ['apps/backend/src/migrations/*.ts'], // use this line instead of the above when running migrations locally, + // then switch back to the above before pushing to github so that it works on the deployment server // Setting synchronize: true shouldn't be used in production - otherwise you can lose production data synchronize: false, namingStrategy: new PluralNamingStrategy(), From d990085836195e1ca771810e19e589b663440d31 Mon Sep 17 00:00:00 2001 From: Owen Stepan <106773727+ostepan8@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:33:02 -0400 Subject: [PATCH 4/8] added admin controller, entity, module, and service --- apps/backend/src/app.module.ts | 3 +- apps/backend/src/users/admin.entity.ts | 34 +++++++++++ apps/backend/src/users/admins.controller.ts | 62 +++++++++++++++++++++ apps/backend/src/users/admins.module.ts | 13 +++++ apps/backend/src/users/admins.service.ts | 61 ++++++++++++++++++++ apps/backend/src/users/types.ts | 4 ++ 6 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/users/admin.entity.ts create mode 100644 apps/backend/src/users/admins.controller.ts create mode 100644 apps/backend/src/users/admins.module.ts create mode 100644 apps/backend/src/users/admins.service.ts diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 9af0cf4b..153e9fe7 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -4,9 +4,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import AppDataSource from './data-source'; +import { AdminsModule } from './users/admins.module'; @Module({ - imports: [TypeOrmModule.forRoot(AppDataSource.options)], + imports: [TypeOrmModule.forRoot(AppDataSource.options), AdminsModule], controllers: [AppController], providers: [AppService], }) diff --git a/apps/backend/src/users/admin.entity.ts b/apps/backend/src/users/admin.entity.ts new file mode 100644 index 00000000..15e8fa5a --- /dev/null +++ b/apps/backend/src/users/admin.entity.ts @@ -0,0 +1,34 @@ +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; +import { Site } from './types'; + +@Entity('admins') +export class Admin { + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; + + @Column({ unique: true }) + email: string; + + @Column({ + type: 'enum', + enum: Site, + }) + site: Site; + + // Automatically sets the timestamp when the record is created. + // Useful for tracking when an admin was added to the system. + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + createdAt: Date; + + // Automatically updates the timestamp whenever the record is modified. + // Helps in auditing changes or knowing the last time the admin's data was updated. + @Column({ + type: 'timestamp', + default: () => 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }) + updatedAt: Date; +} diff --git a/apps/backend/src/users/admins.controller.ts b/apps/backend/src/users/admins.controller.ts new file mode 100644 index 00000000..b49b749c --- /dev/null +++ b/apps/backend/src/users/admins.controller.ts @@ -0,0 +1,62 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + ParseIntPipe, +} from '@nestjs/common'; +import { + AdminsService, + CreateAdminDto, + UpdateAdminDto, +} from './admins.service'; +import { Admin } from './admin.entity'; +import { Site } from './types'; + +@Controller('admins') +export class AdminsController { + constructor(private readonly adminsService: AdminsService) {} + + @Post() + async create(@Body() createAdminDto: CreateAdminDto): Promise { + return await this.adminsService.create(createAdminDto); + } + + @Get() + async findAll(@Query('site') site?: Site): Promise { + if (site) { + return await this.adminsService.findBySite(site); + } + return await this.adminsService.findAll(); + } + + @Get(':id') + async findOne(@Param('id', ParseIntPipe) id: number): Promise { + return await this.adminsService.findOne(id); + } + + @Get('email/:email') + async findByEmail(@Param('email') email: string): Promise { + return await this.adminsService.findByEmail(email); + } + + @Patch(':id') + async update( + @Param('id', ParseIntPipe) id: number, + @Body() updateAdminDto: UpdateAdminDto, + ): Promise { + return await this.adminsService.update(id, updateAdminDto); + } + + @Delete(':id') + async remove( + @Param('id', ParseIntPipe) id: number, + ): Promise<{ message: string }> { + await this.adminsService.remove(id); + return { message: `Admin with ID ${id} has been deleted` }; + } +} diff --git a/apps/backend/src/users/admins.module.ts b/apps/backend/src/users/admins.module.ts new file mode 100644 index 00000000..9a2dc1e4 --- /dev/null +++ b/apps/backend/src/users/admins.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AdminsService } from './admins.service'; +import { AdminsController } from './admins.controller'; +import { Admin } from './admin.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Admin])], + controllers: [AdminsController], + providers: [AdminsService], + exports: [AdminsService], +}) +export class AdminsModule {} diff --git a/apps/backend/src/users/admins.service.ts b/apps/backend/src/users/admins.service.ts new file mode 100644 index 00000000..e29db6cc --- /dev/null +++ b/apps/backend/src/users/admins.service.ts @@ -0,0 +1,61 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Admin } from './admin.entity'; +import { Site } from './types'; + +export interface CreateAdminDto { + name: string; + email: string; + site: Site; +} + +export interface UpdateAdminDto { + name?: string; + email?: string; + site?: Site; +} + +@Injectable() +export class AdminsService { + constructor( + @InjectRepository(Admin) + private readonly adminRepository: Repository, + ) {} + + async create(createAdminDto: CreateAdminDto): Promise { + const admin = this.adminRepository.create(createAdminDto); + return await this.adminRepository.save(admin); + } + + async findAll(): Promise { + return await this.adminRepository.find(); + } + + async findOne(id: number): Promise { + const admin = await this.adminRepository.findOne({ where: { id } }); + if (!admin) { + throw new NotFoundException(`Admin with ID ${id} not found`); + } + return admin; + } + + async findByEmail(email: string): Promise { + return await this.adminRepository.findOne({ where: { email } }); + } + + async findBySite(site: Site): Promise { + return await this.adminRepository.find({ where: { site } }); + } + + async update(id: number, updateAdminDto: UpdateAdminDto): Promise { + const admin = await this.findOne(id); + Object.assign(admin, updateAdminDto); + return await this.adminRepository.save(admin); + } + + async remove(id: number): Promise { + const admin = await this.findOne(id); + await this.adminRepository.remove(admin); + } +} diff --git a/apps/backend/src/users/types.ts b/apps/backend/src/users/types.ts index dd9a359b..0b566e44 100644 --- a/apps/backend/src/users/types.ts +++ b/apps/backend/src/users/types.ts @@ -2,3 +2,7 @@ export enum Status { ADMIN = 'ADMIN', STANDARD = 'STANDARD', } + +export enum Site { + Fenway, +} From ab3e30cbf8436e0e77a2839aee40cf74a6ffb2c0 Mon Sep 17 00:00:00 2001 From: Owen Stepan <106773727+ostepan8@users.noreply.github.com> Date: Wed, 1 Oct 2025 11:16:52 -0400 Subject: [PATCH 5/8] small changes to ensure functionality --- apps/backend/src/app.module.ts | 9 ++- apps/backend/src/data-source.ts | 3 +- .../1728000000000-CreateAdminsTable.ts | 63 +++++++++++++++++++ apps/backend/src/users/types.ts | 6 +- 4 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 apps/backend/src/migrations/1728000000000-CreateAdminsTable.ts diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 153e9fe7..429821e2 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -5,9 +5,16 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import AppDataSource from './data-source'; import { AdminsModule } from './users/admins.module'; +import { Admin } from './users/admin.entity'; @Module({ - imports: [TypeOrmModule.forRoot(AppDataSource.options), AdminsModule], + imports: [ + TypeOrmModule.forRoot({ + ...AppDataSource.options, + entities: [Admin], + }), + AdminsModule, + ], controllers: [AppController], providers: [AppService], }) diff --git a/apps/backend/src/data-source.ts b/apps/backend/src/data-source.ts index dee1429a..ea36f9d0 100644 --- a/apps/backend/src/data-source.ts +++ b/apps/backend/src/data-source.ts @@ -1,4 +1,5 @@ import { DataSource } from 'typeorm'; +import { Admin } from './users/admin.entity'; import { PluralNamingStrategy } from './strategies/plural-naming.strategy'; import * as dotenv from 'dotenv'; @@ -11,7 +12,7 @@ const AppDataSource = new DataSource({ username: process.env.NX_DB_USERNAME, password: process.env.NX_DB_PASSWORD, database: process.env.NX_DB_DATABASE, - entities: [], + entities: [Admin], migrations: ['apps/backend/src/migrations/*.js'], // migrations: ['apps/backend/src/migrations/*.ts'], // use this line instead of the above when running migrations locally, // then switch back to the above before pushing to github so that it works on the deployment server diff --git a/apps/backend/src/migrations/1728000000000-CreateAdminsTable.ts b/apps/backend/src/migrations/1728000000000-CreateAdminsTable.ts new file mode 100644 index 00000000..8c2d580f --- /dev/null +++ b/apps/backend/src/migrations/1728000000000-CreateAdminsTable.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner, Table } from 'typeorm'; + +export class CreateAdminsTable1728000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Create the enum type first + await queryRunner.query(` + CREATE TYPE admins_site_enum AS ENUM ('fenway', 'site_a') + `); + + // Create the table + await queryRunner.createTable( + new Table({ + name: 'admins', + columns: [ + { + name: 'id', + type: 'int', + isPrimary: true, + isGenerated: true, + generationStrategy: 'increment', + }, + { + name: 'name', + type: 'varchar', + isNullable: false, + }, + { + name: 'email', + type: 'varchar', + isUnique: true, + isNullable: false, + }, + { + name: 'site', + type: 'enum', + enumName: 'admins_site_enum', + enum: ['fenway', 'site_a'], + isNullable: false, + }, + { + name: 'createdAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + isNullable: false, + }, + { + name: 'updatedAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + isNullable: false, + }, + ], + }), + true, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('admins'); + await queryRunner.query('DROP TYPE IF EXISTS admins_site_enum'); + } +} diff --git a/apps/backend/src/users/types.ts b/apps/backend/src/users/types.ts index 0b566e44..0332d902 100644 --- a/apps/backend/src/users/types.ts +++ b/apps/backend/src/users/types.ts @@ -4,5 +4,9 @@ export enum Status { } export enum Site { - Fenway, + FENWAY = 'fenway', + SITE_A = 'site_a', + // Add more sites as needed + // SITE_B = 'site_b', + // SITE_C = 'site_c', } From bbdd87e8bf1e469cc941b0ace100adee1440d4f3 Mon Sep 17 00:00:00 2001 From: Owen Stepan <106773727+ostepan8@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:39:35 -0400 Subject: [PATCH 6/8] updated pr for comments added testing removed deprecated on update adding user authentication by adding provider deleted old migration file and used just the main one --- .../1728000000000-CreateAdminsTable.ts | 63 ---- .../src/migrations/1754254886189-init.ts | 19 ++ apps/backend/src/users/admin.entity.ts | 20 +- apps/backend/src/users/admins.controller.ts | 3 + apps/backend/src/users/admins.module.ts | 3 +- apps/backend/src/users/admins.service.spec.ts | 273 ++++++++++++++++++ 6 files changed, 306 insertions(+), 75 deletions(-) delete mode 100644 apps/backend/src/migrations/1728000000000-CreateAdminsTable.ts create mode 100644 apps/backend/src/users/admins.service.spec.ts diff --git a/apps/backend/src/migrations/1728000000000-CreateAdminsTable.ts b/apps/backend/src/migrations/1728000000000-CreateAdminsTable.ts deleted file mode 100644 index 8c2d580f..00000000 --- a/apps/backend/src/migrations/1728000000000-CreateAdminsTable.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { MigrationInterface, QueryRunner, Table } from 'typeorm'; - -export class CreateAdminsTable1728000000000 implements MigrationInterface { - public async up(queryRunner: QueryRunner): Promise { - // Create the enum type first - await queryRunner.query(` - CREATE TYPE admins_site_enum AS ENUM ('fenway', 'site_a') - `); - - // Create the table - await queryRunner.createTable( - new Table({ - name: 'admins', - columns: [ - { - name: 'id', - type: 'int', - isPrimary: true, - isGenerated: true, - generationStrategy: 'increment', - }, - { - name: 'name', - type: 'varchar', - isNullable: false, - }, - { - name: 'email', - type: 'varchar', - isUnique: true, - isNullable: false, - }, - { - name: 'site', - type: 'enum', - enumName: 'admins_site_enum', - enum: ['fenway', 'site_a'], - isNullable: false, - }, - { - name: 'createdAt', - type: 'timestamp', - default: 'CURRENT_TIMESTAMP', - isNullable: false, - }, - { - name: 'updatedAt', - type: 'timestamp', - default: 'CURRENT_TIMESTAMP', - onUpdate: 'CURRENT_TIMESTAMP', - isNullable: false, - }, - ], - }), - true, - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.dropTable('admins'); - await queryRunner.query('DROP TYPE IF EXISTS admins_site_enum'); - } -} diff --git a/apps/backend/src/migrations/1754254886189-init.ts b/apps/backend/src/migrations/1754254886189-init.ts index d35b9233..7cfa03e3 100644 --- a/apps/backend/src/migrations/1754254886189-init.ts +++ b/apps/backend/src/migrations/1754254886189-init.ts @@ -28,6 +28,10 @@ export class Init1754254886189 implements MigrationInterface { `CREATE TYPE "public"."interest_area_enum" AS ENUM('Nursing', 'HarmReduction', 'WomensHealth')`, ); + await queryRunner.query( + `CREATE TYPE "public"."admins_site_enum" AS ENUM('fenway', 'site_a')`, + ); + await queryRunner.query( `CREATE TABLE "admin" ( "id" SERIAL NOT NULL, @@ -37,6 +41,18 @@ export class Init1754254886189 implements MigrationInterface { )`, ); + await queryRunner.query( + `CREATE TABLE "admins" ( + "id" SERIAL NOT NULL, + "name" character varying NOT NULL, + "email" character varying NOT NULL UNIQUE, + "site" "public"."admins_site_enum" NOT NULL, + "createdAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PK_admins_id" PRIMARY KEY ("id") + )`, + ); + await queryRunner.query( `CREATE TABLE "discipline" ( "id" SERIAL NOT NULL, @@ -77,16 +93,19 @@ export class Init1754254886189 implements MigrationInterface { )`, ); } + public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP TABLE "learner"`); await queryRunner.query(`DROP TABLE "application"`); await queryRunner.query(`DROP TABLE "discipline"`); + await queryRunner.query(`DROP TABLE "admins"`); await queryRunner.query(`DROP TABLE "admin"`); await queryRunner.query(`DROP TYPE "public"."interest_area_enum"`); await queryRunner.query(`DROP TYPE "public"."experience_type_enum"`); await queryRunner.query(`DROP TYPE "public"."school_enum"`); await queryRunner.query(`DROP TYPE "public"."app_status_enum"`); await queryRunner.query(`DROP TYPE "public"."site_enum"`); + await queryRunner.query(`DROP TYPE "public"."admins_site_enum"`); await queryRunner.query(`DROP TYPE "public"."commit_length_enum"`); } } diff --git a/apps/backend/src/users/admin.entity.ts b/apps/backend/src/users/admin.entity.ts index 15e8fa5a..b86fa372 100644 --- a/apps/backend/src/users/admin.entity.ts +++ b/apps/backend/src/users/admin.entity.ts @@ -1,4 +1,10 @@ -import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; import { Site } from './types'; @Entity('admins') @@ -18,17 +24,9 @@ export class Admin { }) site: Site; - // Automatically sets the timestamp when the record is created. - // Useful for tracking when an admin was added to the system. - @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + @CreateDateColumn({ type: 'timestamp' }) createdAt: Date; - // Automatically updates the timestamp whenever the record is modified. - // Helps in auditing changes or knowing the last time the admin's data was updated. - @Column({ - type: 'timestamp', - default: () => 'CURRENT_TIMESTAMP', - onUpdate: 'CURRENT_TIMESTAMP', - }) + @UpdateDateColumn({ type: 'timestamp' }) updatedAt: Date; } diff --git a/apps/backend/src/users/admins.controller.ts b/apps/backend/src/users/admins.controller.ts index b49b749c..5a017970 100644 --- a/apps/backend/src/users/admins.controller.ts +++ b/apps/backend/src/users/admins.controller.ts @@ -8,6 +8,7 @@ import { Delete, Query, ParseIntPipe, + UseInterceptors, } from '@nestjs/common'; import { AdminsService, @@ -16,8 +17,10 @@ import { } from './admins.service'; import { Admin } from './admin.entity'; import { Site } from './types'; +import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; @Controller('admins') +@UseInterceptors(CurrentUserInterceptor) // Apply authentication to all routes export class AdminsController { constructor(private readonly adminsService: AdminsService) {} diff --git a/apps/backend/src/users/admins.module.ts b/apps/backend/src/users/admins.module.ts index 9a2dc1e4..7285f846 100644 --- a/apps/backend/src/users/admins.module.ts +++ b/apps/backend/src/users/admins.module.ts @@ -3,11 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AdminsService } from './admins.service'; import { AdminsController } from './admins.controller'; import { Admin } from './admin.entity'; +import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; @Module({ imports: [TypeOrmModule.forFeature([Admin])], controllers: [AdminsController], - providers: [AdminsService], + providers: [AdminsService, CurrentUserInterceptor], exports: [AdminsService], }) export class AdminsModule {} diff --git a/apps/backend/src/users/admins.service.spec.ts b/apps/backend/src/users/admins.service.spec.ts new file mode 100644 index 00000000..f8b4db45 --- /dev/null +++ b/apps/backend/src/users/admins.service.spec.ts @@ -0,0 +1,273 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { + AdminsService, + CreateAdminDto, + UpdateAdminDto, +} from './admins.service'; +import { Admin } from './admin.entity'; +import { Site } from './types'; + +describe('AdminsService', () => { + let service: AdminsService; + let repository: Repository; + + const mockRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + remove: jest.fn(), + }; + + const mockAdmin: Admin = { + id: 1, + name: 'John Doe', + email: 'john@example.com', + site: Site.FENWAY, + createdAt: new Date('2025-01-01'), + updatedAt: new Date('2025-01-01'), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminsService, + { + provide: getRepositoryToken(Admin), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(AdminsService); + repository = module.get>(getRepositoryToken(Admin)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should create and save a new admin', async () => { + const createAdminDto: CreateAdminDto = { + name: 'John Doe', + email: 'john@example.com', + site: Site.FENWAY, + }; + + mockRepository.create.mockReturnValue(mockAdmin); + mockRepository.save.mockResolvedValue(mockAdmin); + + const result = await service.create(createAdminDto); + + expect(mockRepository.create).toHaveBeenCalledWith(createAdminDto); + expect(mockRepository.save).toHaveBeenCalledWith(mockAdmin); + expect(result).toEqual(mockAdmin); + }); + + it('should handle repository errors during creation', async () => { + const createAdminDto: CreateAdminDto = { + name: 'John Doe', + email: 'john@example.com', + site: Site.FENWAY, + }; + + mockRepository.create.mockReturnValue(mockAdmin); + mockRepository.save.mockRejectedValue(new Error('Database error')); + + await expect(service.create(createAdminDto)).rejects.toThrow( + 'Database error', + ); + }); + }); + + describe('findAll', () => { + it('should return an array of admins', async () => { + const mockAdmins = [ + mockAdmin, + { ...mockAdmin, id: 2, email: 'jane@example.com' }, + ]; + mockRepository.find.mockResolvedValue(mockAdmins); + + const result = await service.findAll(); + + expect(mockRepository.find).toHaveBeenCalled(); + expect(result).toEqual(mockAdmins); + }); + + it('should return empty array when no admins exist', async () => { + mockRepository.find.mockResolvedValue([]); + + const result = await service.findAll(); + + expect(result).toEqual([]); + }); + }); + + describe('findOne', () => { + it('should return an admin by id', async () => { + mockRepository.findOne.mockResolvedValue(mockAdmin); + + const result = await service.findOne(1); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ where: { id: 1 } }); + expect(result).toEqual(mockAdmin); + }); + + it('should throw NotFoundException when admin not found', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.findOne(999)).rejects.toThrow( + new NotFoundException('Admin with ID 999 not found'), + ); + }); + }); + + describe('findByEmail', () => { + it('should return an admin by email', async () => { + mockRepository.findOne.mockResolvedValue(mockAdmin); + + const result = await service.findByEmail('john@example.com'); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { email: 'john@example.com' }, + }); + expect(result).toEqual(mockAdmin); + }); + + it('should return null when admin not found by email', async () => { + mockRepository.findOne.mockResolvedValue(null); + + const result = await service.findByEmail('notfound@example.com'); + + expect(result).toBeNull(); + }); + }); + + describe('findBySite', () => { + it('should return admins filtered by site', async () => { + const mockAdmins = [mockAdmin]; + mockRepository.find.mockResolvedValue(mockAdmins); + + const result = await service.findBySite(Site.FENWAY); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { site: Site.FENWAY }, + }); + expect(result).toEqual(mockAdmins); + }); + + it('should return empty array when no admins found for site', async () => { + mockRepository.find.mockResolvedValue([]); + + const result = await service.findBySite(Site.SITE_A); + + expect(result).toEqual([]); + }); + }); + + describe('update', () => { + it('should update and return the admin', async () => { + const updateAdminDto: UpdateAdminDto = { + name: 'John Updated', + site: Site.SITE_A, + }; + + const updatedAdmin = { ...mockAdmin, ...updateAdminDto }; + + // Mock findOne (called by update method) + mockRepository.findOne.mockResolvedValue(mockAdmin); + mockRepository.save.mockResolvedValue(updatedAdmin); + + const result = await service.update(1, updateAdminDto); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ where: { id: 1 } }); + expect(mockRepository.save).toHaveBeenCalledWith( + expect.objectContaining(updateAdminDto), + ); + expect(result).toEqual(updatedAdmin); + }); + + it('should throw NotFoundException when admin not found for update', async () => { + const updateAdminDto: UpdateAdminDto = { + name: 'John Updated', + }; + + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.update(999, updateAdminDto)).rejects.toThrow( + new NotFoundException('Admin with ID 999 not found'), + ); + }); + + it('should handle partial updates', async () => { + const updateAdminDto: UpdateAdminDto = { + name: 'John Updated', + // site and email not provided + }; + + const updatedAdmin = { ...mockAdmin, name: 'John Updated' }; + + mockRepository.findOne.mockResolvedValue(mockAdmin); + mockRepository.save.mockResolvedValue(updatedAdmin); + + const result = await service.update(1, updateAdminDto); + + expect(result.name).toBe('John Updated'); + expect(result.email).toBe(mockAdmin.email); // Should remain unchanged + expect(result.site).toBe(mockAdmin.site); // Should remain unchanged + }); + }); + + describe('remove', () => { + it('should remove an admin', async () => { + mockRepository.findOne.mockResolvedValue(mockAdmin); + mockRepository.remove.mockResolvedValue(mockAdmin); + + await service.remove(1); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ where: { id: 1 } }); + expect(mockRepository.remove).toHaveBeenCalledWith(mockAdmin); + }); + + it('should throw NotFoundException when admin not found for removal', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.remove(999)).rejects.toThrow( + new NotFoundException('Admin with ID 999 not found'), + ); + }); + }); + + describe('edge cases', () => { + it('should handle database connection errors', async () => { + mockRepository.find.mockRejectedValue(new Error('Connection failed')); + + await expect(service.findAll()).rejects.toThrow('Connection failed'); + }); + + it('should handle invalid email format in findByEmail', async () => { + mockRepository.findOne.mockResolvedValue(null); + + const result = await service.findByEmail('invalid-email'); + + expect(result).toBeNull(); + }); + + it('should handle concurrent updates gracefully', async () => { + const updateDto: UpdateAdminDto = { name: 'Updated Name' }; + + mockRepository.findOne.mockResolvedValue(mockAdmin); + mockRepository.save.mockRejectedValue( + new Error('Concurrent modification'), + ); + + await expect(service.update(1, updateDto)).rejects.toThrow( + 'Concurrent modification', + ); + }); + }); +}); From ce1d5a74409a3ab74adc0420d557008ff326158c Mon Sep 17 00:00:00 2001 From: Owen Stepan <106773727+ostepan8@users.noreply.github.com> Date: Wed, 8 Oct 2025 20:17:18 -0400 Subject: [PATCH 7/8] changed the update to only update the email --- apps/backend/src/users/admins.controller.ts | 10 +-- apps/backend/src/users/admins.service.spec.ts | 61 +++++++++---------- apps/backend/src/users/admins.service.ts | 13 ++-- 3 files changed, 41 insertions(+), 43 deletions(-) diff --git a/apps/backend/src/users/admins.controller.ts b/apps/backend/src/users/admins.controller.ts index 5a017970..878933db 100644 --- a/apps/backend/src/users/admins.controller.ts +++ b/apps/backend/src/users/admins.controller.ts @@ -13,7 +13,7 @@ import { import { AdminsService, CreateAdminDto, - UpdateAdminDto, + UpdateAdminEmailDto, } from './admins.service'; import { Admin } from './admin.entity'; import { Site } from './types'; @@ -47,12 +47,12 @@ export class AdminsController { return await this.adminsService.findByEmail(email); } - @Patch(':id') - async update( + @Patch(':id/email') + async updateEmail( @Param('id', ParseIntPipe) id: number, - @Body() updateAdminDto: UpdateAdminDto, + @Body() updateEmailDto: UpdateAdminEmailDto, ): Promise { - return await this.adminsService.update(id, updateAdminDto); + return await this.adminsService.updateEmail(id, updateEmailDto); } @Delete(':id') diff --git a/apps/backend/src/users/admins.service.spec.ts b/apps/backend/src/users/admins.service.spec.ts index f8b4db45..1a23b28a 100644 --- a/apps/backend/src/users/admins.service.spec.ts +++ b/apps/backend/src/users/admins.service.spec.ts @@ -5,7 +5,7 @@ import { NotFoundException } from '@nestjs/common'; import { AdminsService, CreateAdminDto, - UpdateAdminDto, + UpdateAdminEmailDto, } from './admins.service'; import { Admin } from './admin.entity'; import { Site } from './types'; @@ -169,56 +169,53 @@ describe('AdminsService', () => { }); }); - describe('update', () => { - it('should update and return the admin', async () => { - const updateAdminDto: UpdateAdminDto = { - name: 'John Updated', - site: Site.SITE_A, + describe('updateEmail', () => { + it('should update admin email and return the admin', async () => { + const updateEmailDto: UpdateAdminEmailDto = { + email: 'newemail@example.com', }; - const updatedAdmin = { ...mockAdmin, ...updateAdminDto }; + const updatedAdmin = { ...mockAdmin, email: 'newemail@example.com' }; - // Mock findOne (called by update method) mockRepository.findOne.mockResolvedValue(mockAdmin); mockRepository.save.mockResolvedValue(updatedAdmin); - const result = await service.update(1, updateAdminDto); + const result = await service.updateEmail(1, updateEmailDto); expect(mockRepository.findOne).toHaveBeenCalledWith({ where: { id: 1 } }); expect(mockRepository.save).toHaveBeenCalledWith( - expect.objectContaining(updateAdminDto), + expect.objectContaining({ email: 'newemail@example.com' }), ); - expect(result).toEqual(updatedAdmin); + expect(result.email).toBe('newemail@example.com'); + expect(result.name).toBe(mockAdmin.name); // Should remain unchanged + expect(result.site).toBe(mockAdmin.site); // Should remain unchanged }); - it('should throw NotFoundException when admin not found for update', async () => { - const updateAdminDto: UpdateAdminDto = { - name: 'John Updated', + it('should throw NotFoundException when admin not found for email update', async () => { + const updateEmailDto: UpdateAdminEmailDto = { + email: 'newemail@example.com', }; mockRepository.findOne.mockResolvedValue(null); - await expect(service.update(999, updateAdminDto)).rejects.toThrow( + await expect(service.updateEmail(999, updateEmailDto)).rejects.toThrow( new NotFoundException('Admin with ID 999 not found'), ); }); - it('should handle partial updates', async () => { - const updateAdminDto: UpdateAdminDto = { - name: 'John Updated', - // site and email not provided + it('should handle email validation', async () => { + const updateEmailDto: UpdateAdminEmailDto = { + email: 'valid@example.com', }; - const updatedAdmin = { ...mockAdmin, name: 'John Updated' }; + const updatedAdmin = { ...mockAdmin, email: 'valid@example.com' }; mockRepository.findOne.mockResolvedValue(mockAdmin); mockRepository.save.mockResolvedValue(updatedAdmin); - const result = await service.update(1, updateAdminDto); + const result = await service.updateEmail(1, updateEmailDto); - expect(result.name).toBe('John Updated'); - expect(result.email).toBe(mockAdmin.email); // Should remain unchanged - expect(result.site).toBe(mockAdmin.site); // Should remain unchanged + expect(result.email).toBe('valid@example.com'); }); }); @@ -257,17 +254,17 @@ describe('AdminsService', () => { expect(result).toBeNull(); }); - it('should handle concurrent updates gracefully', async () => { - const updateDto: UpdateAdminDto = { name: 'Updated Name' }; + it('should handle email update with same email', async () => { + const updateEmailDto: UpdateAdminEmailDto = { + email: 'john@example.com', // Same as current email + }; mockRepository.findOne.mockResolvedValue(mockAdmin); - mockRepository.save.mockRejectedValue( - new Error('Concurrent modification'), - ); + mockRepository.save.mockResolvedValue(mockAdmin); - await expect(service.update(1, updateDto)).rejects.toThrow( - 'Concurrent modification', - ); + const result = await service.updateEmail(1, updateEmailDto); + + expect(result.email).toBe('john@example.com'); }); }); }); diff --git a/apps/backend/src/users/admins.service.ts b/apps/backend/src/users/admins.service.ts index e29db6cc..6d1b0b69 100644 --- a/apps/backend/src/users/admins.service.ts +++ b/apps/backend/src/users/admins.service.ts @@ -10,10 +10,8 @@ export interface CreateAdminDto { site: Site; } -export interface UpdateAdminDto { - name?: string; - email?: string; - site?: Site; +export interface UpdateAdminEmailDto { + email: string; } @Injectable() @@ -48,9 +46,12 @@ export class AdminsService { return await this.adminRepository.find({ where: { site } }); } - async update(id: number, updateAdminDto: UpdateAdminDto): Promise { + async updateEmail( + id: number, + updateEmailDto: UpdateAdminEmailDto, + ): Promise { const admin = await this.findOne(id); - Object.assign(admin, updateAdminDto); + admin.email = updateEmailDto.email; return await this.adminRepository.save(admin); } From 3ddea89cb76e2d8a7024c1e1da1eaa5bc4517147 Mon Sep 17 00:00:00 2001 From: Owen Stepan <106773727+ostepan8@users.noreply.github.com> Date: Wed, 8 Oct 2025 20:23:21 -0400 Subject: [PATCH 8/8] make migration dynamic --- apps/backend/src/migrations/1754254886189-init.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/migrations/1754254886189-init.ts b/apps/backend/src/migrations/1754254886189-init.ts index 7cfa03e3..b640ffaf 100644 --- a/apps/backend/src/migrations/1754254886189-init.ts +++ b/apps/backend/src/migrations/1754254886189-init.ts @@ -1,4 +1,5 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; +import { Site } from '../users/types'; export class Init1754254886189 implements MigrationInterface { name = 'Init1754254886189'; @@ -28,8 +29,12 @@ export class Init1754254886189 implements MigrationInterface { `CREATE TYPE "public"."interest_area_enum" AS ENUM('Nursing', 'HarmReduction', 'WomensHealth')`, ); + // Use Site enum values dynamically + const siteValues = Object.values(Site) + .map((site) => `'${site}'`) + .join(', '); await queryRunner.query( - `CREATE TYPE "public"."admins_site_enum" AS ENUM('fenway', 'site_a')`, + `CREATE TYPE "public"."admins_site_enum" AS ENUM(${siteValues})`, ); await queryRunner.query(