diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 54fb044e..429821e2 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -3,11 +3,18 @@ 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'; +import { AdminsModule } from './users/admins.module'; +import { Admin } from './users/admin.entity'; @Module({ - imports: [TypeOrmModule.forRoot(AppDataSource.options), TaskModule], + 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 4cd06624..ea36f9d0 100644 --- a/apps/backend/src/data-source.ts +++ b/apps/backend/src/data-source.ts @@ -1,6 +1,6 @@ import { DataSource } from 'typeorm'; +import { Admin } from './users/admin.entity'; import { PluralNamingStrategy } from './strategies/plural-naming.strategy'; -import { Task } from './task/types/task.entity'; import * as dotenv from 'dotenv'; dotenv.config(); @@ -12,8 +12,10 @@ 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: [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 // Setting synchronize: true shouldn't be used in production - otherwise you can lose production data synchronize: false, namingStrategy: new PluralNamingStrategy(), 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..b640ffaf --- /dev/null +++ b/apps/backend/src/migrations/1754254886189-init.ts @@ -0,0 +1,116 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { Site } from '../users/types'; + +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')`, + ); + + // 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(${siteValues})`, + ); + + 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 "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, + "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 "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 new file mode 100644 index 00000000..b86fa372 --- /dev/null +++ b/apps/backend/src/users/admin.entity.ts @@ -0,0 +1,32 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} 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; + + @CreateDateColumn({ type: 'timestamp' }) + createdAt: Date; + + @UpdateDateColumn({ type: '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..878933db --- /dev/null +++ b/apps/backend/src/users/admins.controller.ts @@ -0,0 +1,65 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + ParseIntPipe, + UseInterceptors, +} from '@nestjs/common'; +import { + AdminsService, + CreateAdminDto, + UpdateAdminEmailDto, +} 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) {} + + @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/email') + async updateEmail( + @Param('id', ParseIntPipe) id: number, + @Body() updateEmailDto: UpdateAdminEmailDto, + ): Promise { + return await this.adminsService.updateEmail(id, updateEmailDto); + } + + @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..7285f846 --- /dev/null +++ b/apps/backend/src/users/admins.module.ts @@ -0,0 +1,14 @@ +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'; +import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; + +@Module({ + imports: [TypeOrmModule.forFeature([Admin])], + controllers: [AdminsController], + 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..1a23b28a --- /dev/null +++ b/apps/backend/src/users/admins.service.spec.ts @@ -0,0 +1,270 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { + AdminsService, + CreateAdminDto, + UpdateAdminEmailDto, +} 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('updateEmail', () => { + it('should update admin email and return the admin', async () => { + const updateEmailDto: UpdateAdminEmailDto = { + email: 'newemail@example.com', + }; + + const updatedAdmin = { ...mockAdmin, email: 'newemail@example.com' }; + + mockRepository.findOne.mockResolvedValue(mockAdmin); + mockRepository.save.mockResolvedValue(updatedAdmin); + + const result = await service.updateEmail(1, updateEmailDto); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ where: { id: 1 } }); + expect(mockRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ email: 'newemail@example.com' }), + ); + 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 email update', async () => { + const updateEmailDto: UpdateAdminEmailDto = { + email: 'newemail@example.com', + }; + + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.updateEmail(999, updateEmailDto)).rejects.toThrow( + new NotFoundException('Admin with ID 999 not found'), + ); + }); + + it('should handle email validation', async () => { + const updateEmailDto: UpdateAdminEmailDto = { + email: 'valid@example.com', + }; + + const updatedAdmin = { ...mockAdmin, email: 'valid@example.com' }; + + mockRepository.findOne.mockResolvedValue(mockAdmin); + mockRepository.save.mockResolvedValue(updatedAdmin); + + const result = await service.updateEmail(1, updateEmailDto); + + expect(result.email).toBe('valid@example.com'); + }); + }); + + 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 email update with same email', async () => { + const updateEmailDto: UpdateAdminEmailDto = { + email: 'john@example.com', // Same as current email + }; + + mockRepository.findOne.mockResolvedValue(mockAdmin); + mockRepository.save.mockResolvedValue(mockAdmin); + + 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 new file mode 100644 index 00000000..6d1b0b69 --- /dev/null +++ b/apps/backend/src/users/admins.service.ts @@ -0,0 +1,62 @@ +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 UpdateAdminEmailDto { + email: string; +} + +@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 updateEmail( + id: number, + updateEmailDto: UpdateAdminEmailDto, + ): Promise { + const admin = await this.findOne(id); + admin.email = updateEmailDto.email; + 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..0332d902 100644 --- a/apps/backend/src/users/types.ts +++ b/apps/backend/src/users/types.ts @@ -2,3 +2,11 @@ export enum Status { ADMIN = 'ADMIN', STANDARD = 'STANDARD', } + +export enum Site { + FENWAY = 'fenway', + SITE_A = 'site_a', + // Add more sites as needed + // SITE_B = 'site_b', + // SITE_C = 'site_c', +} diff --git a/example.env b/example.env index 211b1472..a4a120c7 100644 --- a/example.env +++ b/example.env @@ -1,5 +1,5 @@ -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 +NX_DB_HOST=localhost +NX_DB_PORT=5432 +NX_DB_USERNAME=postgres +NX_DB_PASSWORD= +NX_DB_DATABASE=bhchp