Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,8 @@ Thumbs.db
# Environment file
*.env
*.env.*
!example.env
!example.env
*.lock
package-lock.json
yarn.lock
yarn.lock
4 changes: 2 additions & 2 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';

import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TaskModule } from './task/task.module';
import { AWSS3Module } from './aws-s3/aws-s3.module';
import AppDataSource from './data-source';

@Module({
imports: [TypeOrmModule.forRoot(AppDataSource.options), TaskModule],
imports: [TypeOrmModule.forRoot(AppDataSource.options), AWSS3Module],
controllers: [AppController],
providers: [AppService],
})
Expand Down
49 changes: 49 additions & 0 deletions apps/backend/src/applications/application.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Entity, Column } from 'typeorm';

import type { AppStatus, ExperienceType, Interest, School } from './types';

@Entity()
export class Application {
@Column({ primary: true })
appId: number;

@Column()
appStatus: AppStatus;

@Column()
daysAvailable: string;

@Column()
experienceType: ExperienceType;

// Array of S3 file URLs
@Column()
fileUploads: string[];

@Column()
interest: Interest;

@Column()
license: string;

@Column()
isInternational: boolean;

@Column()
isLearner: boolean;

@Column()
phone: string;

@Column()
school: School;

@Column()
referred: boolean;

@Column()
referredEmail: string;

@Column()
weeklyHours: number;
}
27 changes: 27 additions & 0 deletions apps/backend/src/applications/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export enum AppStatus {
APP_SUBMITTED = 'APP_SUBMITTED',
IN_REVIEW = 'IN_REVIEW',
FORMS_SENT = 'FORMS_SENT',
ACCEPTED = 'ACCEPTED',
REJECTED = 'REJECTED',
}

export enum ExperienceType {
// Dummy values, replace with actual experience types
VOLUNTEER = 'VOLUNTEER',
}

export enum Interest {
// Dummy values, replace with actual interests
PHARMACY = 'Pharmacy',
TECHNOLOGY = 'Technology',
FINANCE = 'Finance',
HEALTH = 'Health',
EDUCATION = 'Education',
}

export enum School {
// Dummy values, replace with actual schools
HARVARD_MEDICAL_SCHOOL = 'Harvard Medical School',
JOHNS_HOPKINS = 'Johns Hopkins',
}
3 changes: 3 additions & 0 deletions apps/backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
Body,
Controller,
Post,
Request,

Check warning on line 6 in apps/backend/src/auth/auth.controller.ts

View workflow job for this annotation

GitHub Actions / pre-deploy

'Request' is defined but never used
UseGuards,

Check warning on line 7 in apps/backend/src/auth/auth.controller.ts

View workflow job for this annotation

GitHub Actions / pre-deploy

'UseGuards' is defined but never used
UseInterceptors,
} from '@nestjs/common';

import { SignInDto } from './dtos/sign-in.dto';
Expand All @@ -16,13 +17,15 @@
import { User } from '../users/user.entity';
import { SignInResponseDto } from './dtos/sign-in-response.dto';
import { RefreshTokenDto } from './dtos/refresh-token.dto';
import { AuthGuard } from '@nestjs/passport';

Check warning on line 20 in apps/backend/src/auth/auth.controller.ts

View workflow job for this annotation

GitHub Actions / pre-deploy

'AuthGuard' is defined but never used
import { ConfirmPasswordDto } from './dtos/confirm-password.dto';
import { ForgotPasswordDto } from './dtos/forgot-password.dto';
import { ApiTags } from '@nestjs/swagger';
import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor';

@ApiTags('Auth')
@Controller('auth')
@UseInterceptors(CurrentUserInterceptor)
export class AuthController {
constructor(
private authService: AuthService,
Expand Down
3 changes: 2 additions & 1 deletion apps/backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
import { User } from '../users/user.entity';
import { JwtStrategy } from './jwt.strategy';
import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor';

@Module({
imports: [
TypeOrmModule.forFeature([User]),
PassportModule.register({ defaultStrategy: 'jwt' }),
],
controllers: [AuthController],
providers: [AuthService, UsersService, JwtStrategy],
providers: [AuthService, UsersService, JwtStrategy, CurrentUserInterceptor],
})
export class AuthModule {}
10 changes: 10 additions & 0 deletions apps/backend/src/aws-s3/aws-s3.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { AWSS3Service } from './aws-s3.service';

@Global()
@Module({
imports: [],
providers: [AWSS3Service],
exports: [AWSS3Service],
})
export class AWSS3Module {}
85 changes: 85 additions & 0 deletions apps/backend/src/aws-s3/aws-s3.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import * as dotenv from 'dotenv';
import path from 'path';

dotenv.config({ path: path.join(__dirname, '../../../.env') });

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { AWSS3Service } from './aws-s3.service';
import { mockClient } from 'aws-sdk-client-mock';
import axios from 'axios';

const s3Mock = mockClient(S3Client);

describe('AWSS3Service', () => {
let service: AWSS3Service;
const bucketName = process.env.AWS_BUCKET_NAME;
const bucketRegion = process.env.AWS_REGION;

beforeEach(() => {
s3Mock.reset();
service = new AWSS3Service();
});

it('should throw error if AWS_BUCKET_NAME is not defined', () => {
delete process.env.AWS_BUCKET_NAME;
expect(() => new AWSS3Service()).toThrow(
'AWS_BUCKET_NAME is not defined in environment variables',
);
process.env.AWS_BUCKET_NAME = bucketName; // restore for other tests
});

it('should create correct S3 link', () => {
const link = service.createLink('JohnDoe', 'Resume');
expect(link).toBe(
`https://${bucketName}.s3.${bucketRegion}.amazonaws.com/JohnDoe-Resume.pdf`,
);
});

it('should upload file and return correct URL', async () => {
s3Mock.on(PutObjectCommand).resolves({});

const buffer = Buffer.from('test');
const fileName = 'file.pdf';
const mimeType = 'application/pdf';

const url = await service.upload(buffer, fileName, mimeType);

expect(s3Mock.calls()).toHaveLength(1);
expect(url).toBe(
`https://${bucketName}.s3.${bucketRegion}.amazonaws.com/${fileName}`,
);
});

it('should throw error on upload failure', async () => {
s3Mock.on(PutObjectCommand).rejects(new Error('fail'));

const buffer = Buffer.from('test');
await expect(
service.upload(buffer, 'file.pdf', 'application/pdf'),
).rejects.toThrow('File upload to AWS failed: Error: fail');
});

// take off ".skip" to run this test but do so sparingly
it.skip('should actually upload a file to S3 (integration)', async () => {
s3Mock.restore();
const fileContent = `integration-test-content-${Date.now()}`;
const buffer = Buffer.from(fileContent);
const fileName = `integration-test-${Date.now()}.txt`;
const mimeType = 'text/plain';
const integrationService = new AWSS3Service();

const url = await integrationService.upload(buffer, fileName, mimeType);
console.log('Uploaded file URL:', url);
expect(url).toContain(fileName);
try {
const response = await axios.get(url);
expect(response.status).toBe(200);
expect(response.data).toBe(fileContent);
} catch (error) {
throw new Error(
`Failed to fetch the uploaded file from S3. Error: ${error.message}.`,
);
}
}, 15000);
// MAKE SURE TO CLEAN UP THE FILES FROM OUR S3 BUCKET AFTER RUNNING THE INTEGRATION TEST
});
66 changes: 66 additions & 0 deletions apps/backend/src/aws-s3/aws-s3.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import * as dotenv from 'dotenv';
import path from 'path';

dotenv.config({ path: path.join(__dirname, '../../../../.env') });

import { Injectable } from '@nestjs/common';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';

@Injectable()
export class AWSS3Service {
private client: S3Client;
private readonly bucketName = process.env.AWS_BUCKET_NAME;
private readonly region: string;

constructor() {
this.region = process.env.AWS_REGION || 'us-east-2';
this.bucketName = process.env.AWS_BUCKET_NAME;

if (!this.bucketName) {
throw new Error(
'AWS_BUCKET_NAME is not defined in environment variables',
);
}

if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) {
throw new Error(
'AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY is not defined in environment variables',
);
}

this.client = new S3Client({
region: this.region,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
});
}

// In the form of "JohnDoe-Resume.pdf"
createLink(person: string, type: string): string {
const fileName = `${person}-${type}.pdf`;
return `https://${this.bucketName}.s3.us-east-2.amazonaws.com/${fileName}`;
}

async upload(
fileBuffer: Buffer,
fileName: string,
mimeType: string,
): Promise<string> {
try {
const command = new PutObjectCommand({
Bucket: this.bucketName,
Key: fileName,
Body: fileBuffer,
ContentType: mimeType,
});

await this.client.send(command);

return `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${fileName}`;
} catch (error) {
throw new Error('File upload to AWS failed: ' + error);
}
}
}
6 changes: 4 additions & 2 deletions apps/backend/src/data-source.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { DataSource } from 'typeorm';
import { PluralNamingStrategy } from './strategies/plural-naming.strategy';
import { Task } from './task/types/task.entity';
import * as dotenv from 'dotenv';
import { Discipline } from './disciplines/disciplines.entity';

dotenv.config();

Expand All @@ -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: [Discipline],
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(),
Expand Down
Empty file.
12 changes: 12 additions & 0 deletions apps/backend/src/disciplines/disciplines.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Controller, UseGuards, UseInterceptors } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor';
import { DisciplinesService } from './disciplines.service';

@Controller('disciplines')
@UseInterceptors(CurrentUserInterceptor)
@UseGuards(AuthGuard('jwt'))
export class DisciplinesController {
constructor(private disciplinesService: DisciplinesService) {}
// TODO: fill out with actual API endpoints
}
22 changes: 22 additions & 0 deletions apps/backend/src/disciplines/disciplines.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { DISCIPLINE_VALUES } from '../disciplines/discplines.constants';

// describes a BHCHP medical discipline
// Current list of disciplines: Volunteers, Nursing, Public Health, MD, PA, NP,
// Research, Social work, Psychiatry, Pharmacy, IT
@Entity('discipline')
export class Discipline {
@PrimaryGeneratedColumn()
id: number;

// enforce discipline names to be one of the predefined values
@Column({
type: 'enum',
enum: DISCIPLINE_VALUES,
nullable: false,
})
name: DISCIPLINE_VALUES;

@Column({ type: 'int', array: true, default: () => "'{}'" })
admin_ids: number[];
}
14 changes: 14 additions & 0 deletions apps/backend/src/disciplines/disciplines.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DisciplinesController } from './disciplines.controller';
import { DisciplinesService } from './disciplines.service';
import { Discipline } from './disciplines.entity';
import { JwtStrategy } from '../auth/jwt.strategy';
import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor';

@Module({
imports: [TypeOrmModule.forFeature([Discipline])],
controllers: [DisciplinesController],
providers: [DisciplinesService, JwtStrategy, CurrentUserInterceptor],
})
export class DiscplinesModule {}
Empty file.
14 changes: 14 additions & 0 deletions apps/backend/src/disciplines/disciplines.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Discipline } from './disciplines.entity';

@Injectable()
export class DisciplinesService {
constructor(
@InjectRepository(Discipline)
private disciplinesRepository: Repository<Discipline>,
) {}

// TODO: fill out with actual methods
}
Loading
Loading