diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dad273c8a..9d373a623 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,5 +1,9 @@ -name: Run tests on PR -"on": pull_request +name: Run all tests +on: + pull_request: + push: + branches: + - main jobs: Run-Tests: runs-on: ubuntu-latest diff --git a/.prettierignore b/.prettierignore index d28581823..7151eed82 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ -**/__generated__ \ No newline at end of file +**/__generated__ +**/*.index.html \ No newline at end of file diff --git a/README.md b/README.md index cdce88931..c5de07c8f 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,19 @@ Project initiated by WDCC in 2023. -## Team Leadership +## 2024 Team Leadership + +- Benson Cho (Tech Lead) +- Eddie Wang (Project Manager) + +## 2024 Team Members + +## 2023 Team Leadership - Bill Wong (Project Manager) - Tony Feng (Tech Lead) -## Team Members +## 2023 Team Members - Atharva Arankalle - Campbell Wood @@ -24,74 +31,6 @@ with the purpose of improving bookings for both users and admins. **2024:** We are focused on providing a functional membership management system for UASC -Tech Stack: - -- React -- Express -- Firebase -- Stripe -- tsoa - -Material UI is used as the styling library. - -## Getting Started - -To begin, run `yarn` at the root (the directory this `README.md` is in). This will install all the dependencies used for this project. - -If you are using VS Code run `yarn vsc-setup` which will install ESLint and Prettier. Other environments like nvim please refer to relevant documentation to set this up. Setting up auto formatting is recommended (for VSC: `File > Preferences > Settings > Text Editor > Formatting`) - -We are using **yarn workspaces** so if you want to (run all these commands from the root if possible): - -**Add any packages:** - -- To add to the client (frontend) run `yarn workspace client add <--dev>(if dev dependency)` -- To add to the server (backend) run `yarn workspace server add <--dev>(if dev dependency)` - -**Run the dev environment:** - -- Start client (frontend) run `yarn dev-client` -- Start server (backend) run `yarn dev-server` - -## Pre commit hooks - -We use `husky` to run linters and formatters on each commit to help ensure that code quality is maintained before pushes. These will be done automatically, however if your device is ~~trash~~ slow then it is acceptable to add `--no-verify` to the end of your `git commit`. However if you are going to push the commit(s) avoid doing this and allow the precommits to run before you push. - -## Code generation - -We make use of `openapi-typescript` to create types for the frontend (based on `swagger.json`) when calling our backend API and `tsoa` to automatically generate routes for our express application and create a `swagger.json`. - -- To generate types for the frontend run `yarn workspace client generate-types` -- To generate routes for the backend run `yarn workspace server tsoa spec-and-routes` - -**Note:** this is automatically handled when running the dev commands - -## Testing - -Testing is handled with jest and tests should be written where possible - -- To test everything, run `yarn test`. Otherwise find the relative path of the file to test use `npx jest `. - -- To test everyting in backend run `yarn workspace server test` (IMPORTANT if you do not have firebase emulator running!) - -- To test everyting in frontend run `yarn test-client` (TODO: fix naming haha) - -## Emulators - -Don't play around with the prod DB during development. Make sure you have firebase cli installed globally with `npm install -g firebase-tools` and run `firebase emulators:start`. Both the server and client should be running using the same config. This is also important when writing integration tests for the backend. - -## ENV File format - -**Client**: - -``` -VITE_FIREBASE_API_KEY= -VITE_ENV= -VITE_BACKEND_BASE_URL= -``` - -**Server**: +## Get started -``` -DEV= -GOOGLE_APPLICATION_CREDENTIALS = .firebase/service-account.json -``` +[Check out the wiki page on onboarding](https://github.com/UoaWDCC/uasc-web/wiki/Onboarding) diff --git a/client/index.html b/client/index.html index aa32e3a2f..45d93dea1 100644 --- a/client/index.html +++ b/client/index.html @@ -1,4 +1,4 @@ - + diff --git a/client/src/models/__generated__/schema.d.ts b/client/src/models/__generated__/schema.d.ts index 8f5e157d5..6230e09fd 100644 --- a/client/src/models/__generated__/schema.d.ts +++ b/client/src/models/__generated__/schema.d.ts @@ -6,8 +6,16 @@ export interface paths { "/users": { - get: operations["GetUser"]; - post: operations["CreateUser"]; + get: operations["GetAllUsers"]; + }; + "/users/self": { + get: operations["GetSelf"]; + }; + "/users/create": { + put: operations["CreateUser"]; + }; + "/users/bulk-edit": { + patch: operations["EditUsers"]; }; } @@ -16,27 +24,28 @@ export type webhooks = Record; export interface components { schemas: { /** - * @description A `Timestamp` represents a point in time independent of any time zone or + * @description A Timestamp represents a point in time independent of any time zone or * calendar, represented as seconds and fractions of seconds at nanosecond - * resolution in UTC Epoch time. - * - * It is encoded using the Proleptic Gregorian Calendar which extends the - * Gregorian calendar backwards to year one. It is encoded assuming all minutes - * are 60 seconds long, i.e. leap seconds are "smeared" so that no leap second - * table is needed for interpretation. Range is from 0001-01-01T00:00:00Z to - * 9999-12-31T23:59:59.999999999Z. - * - * For examples and further specifications, refer to the - * {@link https://github.com/google/protobuf/blob/master/src/google/protobuf/timestamp.proto Timestamp definition}. + * resolution in UTC Epoch time. It is encoded using the Proleptic Gregorian + * Calendar which extends the Gregorian calendar backwards to year one. It is + * encoded assuming all minutes are 60 seconds long, i.e. leap seconds are + * "smeared" so that no leap second table is needed for interpretation. Range + * is from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. */ - Timestamp: { - /** Format: double */ - nanoseconds: number; - /** Format: double */ + "FirebaseFirestore.Timestamp": { + /** + * Format: double + * @description The number of seconds of UTC time since Unix epoch 1970-01-01T00:00:00Z. + */ seconds: number; + /** + * Format: double + * @description The non-negative fractions of a second at nanosecond resolution. + */ + nanoseconds: number; }; UserAdditionalInfo: { - date_of_birth: components["schemas"]["Timestamp"]; + date_of_birth: components["schemas"]["FirebaseFirestore.Timestamp"]; does_freestyle: boolean; does_racing: boolean; does_ski: boolean; @@ -48,6 +57,20 @@ export interface components { /** @enum {string} */ membership: "admin" | "member"; }; + FirebaseProperties: { + uid: string; + }; + UserResponse: components["schemas"]["UserAdditionalInfo"] & components["schemas"]["FirebaseProperties"]; + CreateUserRequestBody: { + uid: string; + user: components["schemas"]["UserAdditionalInfo"]; + }; + EditUsersRequestBody: { + users: { + updatedInformation: components["schemas"]["UserAdditionalInfo"]; + uid: string; + }[]; + }; }; responses: { }; @@ -66,12 +89,22 @@ export type external = Record; export interface operations { - GetUser: { + GetAllUsers: { + responses: { + /** @description Users found */ + 200: { + content: { + "application/json": components["schemas"]["UserResponse"][]; + }; + }; + }; + }; + GetSelf: { responses: { - /** @description Ok */ + /** @description Fetched self data */ 200: { content: { - "application/json": components["schemas"]["UserAdditionalInfo"][]; + "application/json": components["schemas"]["UserResponse"]; }; }; }; @@ -79,9 +112,7 @@ export interface operations { CreateUser: { requestBody: { content: { - "application/json": { - id: string; - }; + "application/json": components["schemas"]["CreateUserRequestBody"]; }; }; responses: { @@ -91,4 +122,17 @@ export interface operations { }; }; }; + EditUsers: { + requestBody: { + content: { + "application/json": components["schemas"]["EditUsersRequestBody"]; + }; + }; + responses: { + /** @description Edited */ + 200: { + content: never; + }; + }; + }; } diff --git a/common/__generated__/swagger.json b/common/__generated__/swagger.json index 69aa62e08..d3f8d81e5 100644 --- a/common/__generated__/swagger.json +++ b/common/__generated__/swagger.json @@ -7,28 +7,31 @@ "requestBodies": {}, "responses": {}, "schemas": { - "Timestamp": { + "FirebaseFirestore.Timestamp": { + "description": "A Timestamp represents a point in time independent of any time zone or\ncalendar, represented as seconds and fractions of seconds at nanosecond\nresolution in UTC Epoch time. It is encoded using the Proleptic Gregorian\nCalendar which extends the Gregorian calendar backwards to year one. It is\nencoded assuming all minutes are 60 seconds long, i.e. leap seconds are\n\"smeared\" so that no leap second table is needed for interpretation. Range\nis from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z.", "properties": { - "nanoseconds": { + "seconds": { "type": "number", - "format": "double" + "format": "double", + "description": "The number of seconds of UTC time since Unix epoch 1970-01-01T00:00:00Z." }, - "seconds": { + "nanoseconds": { "type": "number", - "format": "double" + "format": "double", + "description": "The non-negative fractions of a second at nanosecond resolution." } }, "required": [ - "nanoseconds", - "seconds" + "seconds", + "nanoseconds" ], "type": "object", - "description": "A `Timestamp` represents a point in time independent of any time zone or\ncalendar, represented as seconds and fractions of seconds at nanosecond\nresolution in UTC Epoch time.\n\nIt is encoded using the Proleptic Gregorian Calendar which extends the\nGregorian calendar backwards to year one. It is encoded assuming all minutes\nare 60 seconds long, i.e. leap seconds are \"smeared\" so that no leap second\ntable is needed for interpretation. Range is from 0001-01-01T00:00:00Z to\n9999-12-31T23:59:59.999999999Z.\n\nFor examples and further specifications, refer to the\n{@link https://github.com/google/protobuf/blob/master/src/google/protobuf/timestamp.proto Timestamp definition}." + "additionalProperties": false }, "UserAdditionalInfo": { "properties": { "date_of_birth": { - "$ref": "#/components/schemas/Timestamp" + "$ref": "#/components/schemas/FirebaseFirestore.Timestamp" }, "does_freestyle": { "type": "boolean" @@ -76,6 +79,71 @@ ], "type": "object", "additionalProperties": false + }, + "FirebaseProperties": { + "properties": { + "uid": { + "type": "string" + } + }, + "required": [ + "uid" + ], + "type": "object", + "additionalProperties": false + }, + "UserResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/UserAdditionalInfo" + }, + { + "$ref": "#/components/schemas/FirebaseProperties" + } + ] + }, + "CreateUserRequestBody": { + "properties": { + "uid": { + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/UserAdditionalInfo" + } + }, + "required": [ + "uid", + "user" + ], + "type": "object", + "additionalProperties": false + }, + "EditUsersRequestBody": { + "properties": { + "users": { + "items": { + "properties": { + "updatedInformation": { + "$ref": "#/components/schemas/UserAdditionalInfo" + }, + "uid": { + "type": "string" + } + }, + "required": [ + "updatedInformation", + "uid" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "users" + ], + "type": "object", + "additionalProperties": false } }, "securitySchemes": { @@ -94,15 +162,15 @@ "paths": { "/users": { "get": { - "operationId": "GetUser", + "operationId": "GetAllUsers", "responses": { "200": { - "description": "Ok", + "description": "Users found", "content": { "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/UserAdditionalInfo" + "$ref": "#/components/schemas/UserResponse" }, "type": "array" } @@ -113,35 +181,86 @@ "security": [ { "jwt": [ - "user" + "admin" ] } ], "parameters": [] - }, - "post": { + } + }, + "/users/self": { + "get": { + "operationId": "GetSelf", + "responses": { + "200": { + "description": "Fetched self data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponse" + } + } + } + } + }, + "security": [ + { + "jwt": [] + } + ], + "parameters": [] + } + }, + "/users/create": { + "put": { "operationId": "CreateUser", "responses": { "200": { "description": "Created" } }, - "security": [], + "security": [ + { + "jwt": [ + "admin" + ] + } + ], "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "properties": { - "id": { - "type": "string" - } - }, - "required": [ - "id" - ], - "type": "object" + "$ref": "#/components/schemas/CreateUserRequestBody" + } + } + } + } + } + }, + "/users/bulk-edit": { + "patch": { + "operationId": "EditUsers", + "responses": { + "200": { + "description": "Edited" + } + }, + "security": [ + { + "jwt": [ + "admin" + ] + } + ], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditUsersRequestBody" } } } diff --git a/server/firebase.json b/server/firebase.json index e46df3964..ef4e57a74 100644 --- a/server/firebase.json +++ b/server/firebase.json @@ -1,4 +1,7 @@ { + "firestore": { + "rules": "firestore.rules" + }, "emulators": { "auth": { "port": 9099 diff --git a/server/firestore.rules b/server/firestore.rules new file mode 100644 index 000000000..99621bc1d --- /dev/null +++ b/server/firestore.rules @@ -0,0 +1,8 @@ +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + match /{document=**} { + allow read, write: if false + } + } +} diff --git a/server/package.json b/server/package.json index b8abd50d4..8ef1419a5 100644 --- a/server/package.json +++ b/server/package.json @@ -8,7 +8,6 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.18.2", - "firebase": "^10.8.1", "firebase-admin": "^12.0.0", "helmet": "^7.1.0", "supertest": "^6.3.4", @@ -39,6 +38,7 @@ "tsoa": "./node_modules/.bin/tsoa", "nodemon": "../node_modules/.bin/nodemon", "build": "tsc -p ./tsconfig.prod.json && tsc-alias", - "serve": "node --es-module-specifier-resolution=node dist/server/src/index.js" + "serve": "node --es-module-specifier-resolution=node dist/server/src/index.js", + "token": "ts-node ./tooling/login-prod.ts" } } diff --git a/server/src/business-layer/security/Authentication.ts b/server/src/business-layer/security/Authentication.ts index 82e449f37..33509e709 100644 --- a/server/src/business-layer/security/Authentication.ts +++ b/server/src/business-layer/security/Authentication.ts @@ -1,5 +1,6 @@ import * as express from "express" -import auth from "./FirebaseAuth" +import { auth } from "./Firebase" +import FireBaseError from "data-layer/utils/FirebaseError" export function expressAuthentication( request: express.Request, @@ -20,18 +21,35 @@ export function expressAuthentication( .verifyIdToken(token) .then((decodedToken) => { const { uid } = decodedToken - auth.getUser(uid).then((user) => { - for (const scope of scopes!) { - if (!user.customClaims![scope]) { - reject(new Error("No scope")) + auth + .getUser(uid) + .then((user) => { + for (const scope of scopes!) { + if (user.customClaims === undefined) { + throw new FireBaseError( + "Authentication Error", + 401, + "No Scope" + ) + } + if (!(scope in user.customClaims)) { + throw new FireBaseError( + "Authentication Error", + 401, + "No Scope" + ) + } } - } - resolve(user) - }) + resolve(user) + }) + .catch((reason) => { + console.error(reason) + reject(new FireBaseError("Authentication Error", 401, reason)) + }) }) .catch((reason) => { console.error(reason) - reject(new Error("Invalid token")) + reject(new FireBaseError("Authentication Error", 401, reason)) }) }) } diff --git a/server/src/business-layer/security/Firebase.ts b/server/src/business-layer/security/Firebase.ts new file mode 100644 index 000000000..907ae138c --- /dev/null +++ b/server/src/business-layer/security/Firebase.ts @@ -0,0 +1,15 @@ +import { + EMULATOR_FIRESTORE_PORT, + EMULATOR_HOST +} from "data-layer/adapters/EmulatorConfig" +import * as _admin from "firebase-admin" + +if (process.env.DEV || process.env.JEST_WORKER_ID !== undefined) { + process.env.FIRESTORE_EMULATOR_HOST = `${EMULATOR_HOST}:${EMULATOR_FIRESTORE_PORT}` +} + +const firebase = _admin.initializeApp() + +export const admin = _admin + +export const auth = firebase.auth() diff --git a/server/src/business-layer/security/FirebaseAuth.ts b/server/src/business-layer/security/FirebaseAuth.ts deleted file mode 100644 index 0bc024ce4..000000000 --- a/server/src/business-layer/security/FirebaseAuth.ts +++ /dev/null @@ -1,5 +0,0 @@ -import * as admin from "firebase-admin" - -const firebase = admin.initializeApp() - -export default firebase.auth() diff --git a/server/src/data-layer/adapters/FirestoreCollections.ts b/server/src/data-layer/adapters/FirestoreCollections.ts index e52660387..d6be2a161 100644 --- a/server/src/data-layer/adapters/FirestoreCollections.ts +++ b/server/src/data-layer/adapters/FirestoreCollections.ts @@ -1,37 +1,29 @@ // credit https://plainenglish.io/blog/using-firestore-with-typescript-in-the-v9-sdk-cf36851bb099 import "dotenv/config" import { UserAdditionalInfo } from "data-layer/models/firebase" -import { - getFirestore, - CollectionReference, - collection, - DocumentData, - connectFirestoreEmulator -} from "firebase/firestore" -import { USERS_COLLECTION } from "./CollectionNames" -import { initializeApp } from "firebase/app" -import { firebaseConfig } from "./FirestoreConfig" -import { - EMULATOR_FIRESTORE_PORT, - EMULATOR_HOST, - EMULATOR_PROJECT_ID -} from "./EmulatorConfig" +import { admin } from "business-layer/security/Firebase" -if (process.env.DEV || process.env.JEST_WORKER_ID !== undefined) { - initializeApp({ projectId: EMULATOR_PROJECT_ID }) -} else { - initializeApp(firebaseConfig) -} +const converter = () => ({ + toFirestore: (data: any) => data, + fromFirestore: (doc: any) => doc.data() as T +}) -export const firestore = getFirestore() +const firestore = Object.assign( + () => { + return admin.firestore() + }, + { + doc: (path: string) => { + return admin.firestore().doc(path).withConverter(converter()) + }, + collection: (path: string) => { + return admin.firestore().collection(path).withConverter(converter()) + } + } +) -if (process.env.DEV || process.env.JEST_WORKER_ID !== undefined) { - connectFirestoreEmulator(firestore, EMULATOR_HOST, EMULATOR_FIRESTORE_PORT) -} +const db = { + users: firestore.collection("users") +} as const -const createCollection = (collectionName: string) => { - return collection(firestore, collectionName) as CollectionReference -} - -export const UsersCollection = - createCollection(USERS_COLLECTION) +export default db diff --git a/server/src/data-layer/adapters/FirestoreConfig.ts b/server/src/data-layer/adapters/FirestoreConfig.ts deleted file mode 100644 index 5117be2b9..000000000 --- a/server/src/data-layer/adapters/FirestoreConfig.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const firebaseConfig = { - apiKey: process.env.API_KEY, - authDomain: process.env.AUTH_DOMAIN, - projectId: process.env.PROJECT_ID, - storageBucket: process.env.STORAGE_BUCKET, - messagingSenderId: process.env.MESSAGING_SENDER_ID, - appId: process.env.APP_ID -} diff --git a/server/src/data-layer/adapters/FirestoreUtils.ts b/server/src/data-layer/adapters/FirestoreUtils.ts index 1e9569d33..c6746bb65 100644 --- a/server/src/data-layer/adapters/FirestoreUtils.ts +++ b/server/src/data-layer/adapters/FirestoreUtils.ts @@ -1,4 +1,4 @@ -import { Timestamp } from "firebase/firestore" +import { Timestamp } from "firebase-admin/firestore" export const firestoreTimestampToDate = (firestoreDate: Timestamp) => { return new Date(firestoreDate.seconds * 1000) // date takes ms diff --git a/server/src/data-layer/models/common.ts b/server/src/data-layer/models/common.ts new file mode 100644 index 000000000..57f26b5ca --- /dev/null +++ b/server/src/data-layer/models/common.ts @@ -0,0 +1,3 @@ +export interface FirebaseProperties { + uid: string +} diff --git a/server/src/data-layer/models/firebase.ts b/server/src/data-layer/models/firebase.ts index 86a8ed372..d780666ab 100644 --- a/server/src/data-layer/models/firebase.ts +++ b/server/src/data-layer/models/firebase.ts @@ -1,4 +1,4 @@ -import { Timestamp } from "firebase/firestore" +import { Timestamp } from "firebase-admin/firestore" export interface UserAdditionalInfo { date_of_birth: Timestamp // Assuming this is a timestamp @@ -25,12 +25,13 @@ export interface UserRequest { user_id: string // Reference to user ID (e.g., /users/lVsOjAp06AfD6atT8bnrVEpcdcg2) booking_id: string // Reference to booking ID (e.g., /bookings/8mYj7rWOMH6hGy4FzMed) query: string - query_type: "cancellation" | "dateChange" // Possible query types status: "unresolved" | "resolved" // Status of the query creation_time: string // Timestamp (e.g., "1970-01-01T00:00:00Z") } -export interface Cancellation extends UserRequest {} +export interface Cancellation extends UserRequest { + query_type: "cancellation" +} /** * @warning Implementors should ensure that the range between @@ -42,6 +43,7 @@ export interface DateChange extends UserRequest { old_check_out: Timestamp new_check_in: Timestamp new_check_out: Timestamp + query_type: "dateChange" // Possible query types } export interface Booking { diff --git a/server/src/data-layer/services/UserDataService.test.ts b/server/src/data-layer/services/UserDataService.test.ts new file mode 100644 index 000000000..4a353bf84 --- /dev/null +++ b/server/src/data-layer/services/UserDataService.test.ts @@ -0,0 +1,68 @@ +import { additionalInfoMock } from "test-config/mocks/User.mock" +import UserDataService from "./UserDataService" +import { cleanFirestore } from "test-config/TestUtils" + +const TEST_UID_1 = "testUser" + +describe("UserService integration tests", () => { + let userService: UserDataService + + afterEach(async () => { + await cleanFirestore() + }) + + beforeEach(() => { + userService = new UserDataService() + }) + + it("should add a user", async () => { + await userService.createUserData(TEST_UID_1, additionalInfoMock) + const user = await userService.getUserData(TEST_UID_1) + + expect(user).toEqual({ ...additionalInfoMock, uid: TEST_UID_1 }) + }) + + it("should know if a user has a document", async () => { + let result = await userService.userDataExists(TEST_UID_1) + expect(result).toEqual(false) + + await userService.createUserData(TEST_UID_1, additionalInfoMock) + result = await userService.userDataExists(TEST_UID_1) + expect(result).toEqual(true) + + await userService.deleteUserData(TEST_UID_1) + result = await userService.userDataExists(TEST_UID_1) + expect(result).toEqual(false) + }) + + it("edit a user", async () => { + await userService.createUserData(TEST_UID_1, additionalInfoMock) + await userService.editUserData(TEST_UID_1, { does_racing: false }) + const user = await userService.getUserData(TEST_UID_1) + + expect(user).toEqual({ + ...additionalInfoMock, + does_racing: false, + uid: TEST_UID_1 + }) + }) + + it("should delete a user", async () => { + await userService.createUserData(TEST_UID_1, additionalInfoMock) + + await userService.deleteUserData(TEST_UID_1) + const user = await userService.getUserData(TEST_UID_1) + + expect(user).not.toEqual({ ...additionalInfoMock, uid: TEST_UID_1 }) + expect(user).toEqual(undefined) + }) + + it("should get all users", async () => { + await userService.createUserData(TEST_UID_1, additionalInfoMock) + await userService.createUserData("testUser2", additionalInfoMock) + + const users = await userService.getAllUserData() + + expect(users.length).toEqual(2) + }) +}) diff --git a/server/src/data-layer/services/UserDataService.ts b/server/src/data-layer/services/UserDataService.ts new file mode 100644 index 000000000..cedd2f458 --- /dev/null +++ b/server/src/data-layer/services/UserDataService.ts @@ -0,0 +1,55 @@ +import FirestoreCollections from "data-layer/adapters/FirestoreCollections" +import { UserAdditionalInfo } from "data-layer/models/firebase" + +export default class UserDataService { + // Create + /** + * Note this is different from creating a user in firebase auth, which should be handled in the business-layer + * + * @param uid + * @param additionalInfo + */ + public async createUserData(uid: string, additionalInfo: UserAdditionalInfo) { + await FirestoreCollections.users.doc(uid).set(additionalInfo) + } + + // Read + public async getAllUserData() { + const res = await FirestoreCollections.users.get() + const users = res.docs.map((user) => { + return { ...user.data(), uid: user.id } + }) + return users + } + + public async getUserData(uid: string) { + const userDoc = await FirestoreCollections.users.doc(uid).get() + const data = userDoc.data() + if (data === undefined) return undefined + return { ...userDoc.data(), uid } + } + + public async userDataExists(uid: string) { + const snapshot = await FirestoreCollections.users.doc(uid).get() + return snapshot.exists + } + + public async getFilteredUsers(filters: Partial) { + // TODO + } + + // Update + public async editUserData( + uid: string, + updatedFields: Partial + ) { + await FirestoreCollections.users + .doc(uid) + .set(updatedFields, { merge: true }) + } + + // Delete + public async deleteUserData(uid: string) { + await FirestoreCollections.users.doc(uid).delete() + } +} diff --git a/server/src/data-layer/services/UserService.test.ts b/server/src/data-layer/services/UserService.test.ts deleted file mode 100644 index 75162e37b..000000000 --- a/server/src/data-layer/services/UserService.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { additionalInfoMock } from "test-config/mocks/User.mock" -import UserService from "./UserService" -import { cleanFirestore } from "test-config/TestUtils" - -describe("UserService integration tests", () => { - let userService: UserService - - afterEach(async () => { - await cleanFirestore() - }) - - beforeEach(() => { - userService = new UserService() - }) - - it("should add a user", async () => { - await userService.addUser("testUser", additionalInfoMock) - const user = await userService.getUser("testUser") - - expect(user).toEqual(additionalInfoMock) - }) - - it("edit a user", async () => { - await userService.addUser("testUser", additionalInfoMock) - await userService.editUser("testUser", { does_racing: false }) - const user = await userService.getUser("testUser") - - expect(user).toEqual({ ...additionalInfoMock, does_racing: false }) - }) - - it("should delete a user", async () => { - await userService.addUser("testUser", additionalInfoMock) - - await userService.deleteUser("testUser") - const user = await userService.getUser("testUser") - - expect(user).not.toEqual(additionalInfoMock) - expect(user).toEqual(undefined) - }) - - it("should get all users", async () => { - await userService.addUser("testUser", additionalInfoMock) - await userService.addUser("testUser1", additionalInfoMock) - - const users = await userService.getUsers() - - expect(users.length).toEqual(2) - }) -}) diff --git a/server/src/data-layer/services/UserService.ts b/server/src/data-layer/services/UserService.ts deleted file mode 100644 index 7c576aaf6..000000000 --- a/server/src/data-layer/services/UserService.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { UsersCollection } from "data-layer/adapters/FirestoreCollections" -import { UserAdditionalInfo } from "data-layer/models/firebase" -import { - deleteDoc, - doc, - getDoc, - getDocs, - query, - setDoc, - updateDoc, - where -} from "firebase/firestore" - -export default class UserService { - // Create - public async addUser(uid: string, additionalInfo: UserAdditionalInfo) { - await setDoc(doc(UsersCollection, uid), additionalInfo) - } - - // Read - public async getUsers() { - const res = await getDocs(UsersCollection) - const users = res.docs.map((user) => { - return user.data() - }) - return users - } - - public async getUser(uid: string) { - const userDoc = await getDoc(doc(UsersCollection, uid)) - return userDoc.data() - } - - public async getFilteredUsers(filters: Partial) { - let q = query(UsersCollection) - for (const filter of Object.keys(filters)) { - const field = filter as keyof UserAdditionalInfo - q = query(q, where(filter, "==", filters[field])) - } - const filteredUsers = await getDocs(q) - return filteredUsers - } - - // Update - public async editUser( - uid: string, - updatedFields: Partial - ) { - const userRef = doc(UsersCollection, uid) - await updateDoc(userRef, updatedFields) - } - - // Delete - public async deleteUser(uid: string) { - const userRef = await doc(UsersCollection, uid) - await deleteDoc(userRef) - } -} diff --git a/server/src/data-layer/utils/FirebaseError.ts b/server/src/data-layer/utils/FirebaseError.ts new file mode 100644 index 000000000..c5541e381 --- /dev/null +++ b/server/src/data-layer/utils/FirebaseError.ts @@ -0,0 +1,8 @@ +export default class FireBaseError extends Error { + protected statusCode: number + constructor(name: string, statusCode: number, message?: string) { + super(message) + this.name = name + this.statusCode = statusCode + } +} diff --git a/server/src/index.ts b/server/src/index.ts index 62ced184e..3c32744d8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,6 +6,7 @@ import { RegisterRoutes } from "middleware/__generated__/routes" import helmet from "helmet" let spec: swaggerUi.JsonObject | undefined +let generatedHtml: string | undefined const importSwaggerJson = async () => { if (!process.env.DEV) { spec = await import("../../common/__generated__/swagger.json") @@ -28,9 +29,10 @@ app.use("/api-docs", swaggerUi.serve, async (_req: Request, res: Response) => { } else { // Prod if (!spec) { - importSwaggerJson() + await importSwaggerJson() + generatedHtml = swaggerUi.generateHTML(spec) } - return res.send(swaggerUi.setup(spec)) + return res.send(generatedHtml) } }) app.get("/", (req: Request, res: Response) => { diff --git a/server/src/middleware/__generated__/routes.ts b/server/src/middleware/__generated__/routes.ts index f568bfc64..b142e72b2 100644 --- a/server/src/middleware/__generated__/routes.ts +++ b/server/src/middleware/__generated__/routes.ts @@ -11,15 +11,19 @@ import type { RequestHandler, Router } from 'express'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa const models: TsoaRoute.Models = { - "Timestamp": { - "dataType": "refAlias", - "type": {"dataType":"nestedObjectLiteral","nestedProperties":{"nanoseconds":{"dataType":"double","required":true},"seconds":{"dataType":"double","required":true}},"validators":{}}, + "FirebaseFirestore.Timestamp": { + "dataType": "refObject", + "properties": { + "seconds": {"dataType":"double","required":true}, + "nanoseconds": {"dataType":"double","required":true}, + }, + "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "UserAdditionalInfo": { "dataType": "refObject", "properties": { - "date_of_birth": {"ref":"Timestamp","required":true}, + "date_of_birth": {"ref":"FirebaseFirestore.Timestamp","required":true}, "does_freestyle": {"dataType":"boolean","required":true}, "does_racing": {"dataType":"boolean","required":true}, "does_ski": {"dataType":"boolean","required":true}, @@ -33,6 +37,36 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "FirebaseProperties": { + "dataType": "refObject", + "properties": { + "uid": {"dataType":"string","required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "UserResponse": { + "dataType": "refAlias", + "type": {"dataType":"intersection","subSchemas":[{"ref":"UserAdditionalInfo"},{"ref":"FirebaseProperties"}],"validators":{}}, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "CreateUserRequestBody": { + "dataType": "refObject", + "properties": { + "uid": {"dataType":"string","required":true}, + "user": {"ref":"UserAdditionalInfo","required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "EditUsersRequestBody": { + "dataType": "refObject", + "properties": { + "users": {"dataType":"array","array":{"dataType":"nestedObjectLiteral","nestedProperties":{"updatedInformation":{"ref":"UserAdditionalInfo","required":true},"uid":{"dataType":"string","required":true}}},"required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa }; const validationService = new ValidationService(models); @@ -44,11 +78,11 @@ export function RegisterRoutes(app: Router) { // Please look into the "controllerPathGlobs" config option described in the readme: https://github.com/lukeautry/tsoa // ########################################################################################################### app.get('/users', - authenticateMiddleware([{"jwt":["user"]}]), + authenticateMiddleware([{"jwt":["admin"]}]), ...(fetchMiddlewares(UsersController)), - ...(fetchMiddlewares(UsersController.prototype.getUser)), + ...(fetchMiddlewares(UsersController.prototype.getAllUsers)), - function UsersController_getUser(request: any, response: any, next: any) { + function UsersController_getAllUsers(request: any, response: any, next: any) { const args = { }; @@ -61,20 +95,47 @@ export function RegisterRoutes(app: Router) { const controller = new UsersController(); - const promise = controller.getUser.apply(controller, validatedArgs as any); - promiseHandler(controller, promise, response, undefined, next); + const promise = controller.getAllUsers.apply(controller, validatedArgs as any); + promiseHandler(controller, promise, response, 200, next); } catch (err) { return next(err); } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - app.post('/users', + app.get('/users/self', + authenticateMiddleware([{"jwt":[]}]), + ...(fetchMiddlewares(UsersController)), + ...(fetchMiddlewares(UsersController.prototype.getSelf)), + + function UsersController_getSelf(request: any, response: any, next: any) { + const args = { + request: {"in":"request","name":"request","required":true,"dataType":"object"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = getValidatedArgs(args, request, response); + + const controller = new UsersController(); + + + const promise = controller.getSelf.apply(controller, validatedArgs as any); + promiseHandler(controller, promise, response, 200, next); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.put('/users/create', + authenticateMiddleware([{"jwt":["admin"]}]), ...(fetchMiddlewares(UsersController)), ...(fetchMiddlewares(UsersController.prototype.createUser)), function UsersController_createUser(request: any, response: any, next: any) { const args = { - requestBody: {"in":"body","name":"requestBody","required":true,"dataType":"nestedObjectLiteral","nestedProperties":{"id":{"dataType":"string","required":true}}}, + requestBody: {"in":"body","name":"requestBody","required":true,"ref":"CreateUserRequestBody"}, }; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa @@ -93,6 +154,32 @@ export function RegisterRoutes(app: Router) { } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.patch('/users/bulk-edit', + authenticateMiddleware([{"jwt":["admin"]}]), + ...(fetchMiddlewares(UsersController)), + ...(fetchMiddlewares(UsersController.prototype.editUsers)), + + function UsersController_editUsers(request: any, response: any, next: any) { + const args = { + requestBody: {"in":"body","name":"requestBody","required":true,"ref":"EditUsersRequestBody"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = getValidatedArgs(args, request, response); + + const controller = new UsersController(); + + + const promise = controller.editUsers.apply(controller, validatedArgs as any); + promiseHandler(controller, promise, response, 200, next); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa diff --git a/server/src/service-layer/controllers/UserController.ts b/server/src/service-layer/controllers/UserController.ts index 9b194b3e3..436879d2b 100644 --- a/server/src/service-layer/controllers/UserController.ts +++ b/server/src/service-layer/controllers/UserController.ts @@ -1,26 +1,71 @@ -import { UserAdditionalInfo } from "data-layer/models/firebase" -import UserService from "data-layer/services/UserService" +import UserDataService from "data-layer/services/UserDataService" +import { + CreateUserRequestBody, + EditUsersRequestBody, + SelfRequestModel +} from "service-layer/request-models/UserRequests" +import { UserResponse } from "service-layer/response-models/UserResponse" import { Body, Controller, Get, - Post, Route, Security, - SuccessResponse + SuccessResponse, + Request, + Patch, + Put } from "tsoa" @Route("users") export class UsersController extends Controller { - @Security("jwt", ["user"]) + @SuccessResponse("200", "Users found") + @Security("jwt", ["admin"]) @Get() - public async getUser(): Promise { - return new UserService().getUsers() + public async getAllUsers(): Promise { + const data = await new UserDataService().getAllUserData() + this.setStatus(200) + return data + } + + @SuccessResponse("200", "Fetched self data") + @Security("jwt") + @Get("self") + public async getSelf( + @Request() request: SelfRequestModel + ): Promise { + this.setStatus(200) + return await new UserDataService().getUserData(request.user.uid) + } + + @SuccessResponse("200", "Created") + @Security("jwt", ["admin"]) + @Put("create") + public async createUser( + @Body() requestBody: CreateUserRequestBody + ): Promise { + const { uid, user } = requestBody + if (await new UserDataService().userDataExists(uid)) { + this.setStatus(409) + return + } + await new UserDataService().createUserData(uid, user) + this.setStatus(200) } - @SuccessResponse("200", "Created") // Custom success response - @Post() - public async createUser(@Body() requestBody: { id: string }): Promise { - this.setStatus(200) // set return status 200 + @SuccessResponse("200", "Edited") + @Security("jwt", ["admin"]) + @Patch("bulk-edit") + public async editUsers( + @Body() requestBody: EditUsersRequestBody + ): Promise { + const userService = new UserDataService() + const users = requestBody.users + const editPromises = users.map((user) => { + const { uid, updatedInformation } = user + return userService.editUserData(uid, updatedInformation) + }) + await Promise.all(editPromises) + this.setStatus(200) } } diff --git a/server/src/service-layer/request-models/UserRequests.ts b/server/src/service-layer/request-models/UserRequests.ts new file mode 100644 index 000000000..46cad6303 --- /dev/null +++ b/server/src/service-layer/request-models/UserRequests.ts @@ -0,0 +1,15 @@ +import { UserAdditionalInfo } from "data-layer/models/firebase" +import { UserRecord } from "firebase-admin/lib/auth/user-record" + +export interface EditUsersRequestBody { + users: { uid: string; updatedInformation: UserAdditionalInfo }[] +} + +export interface CreateUserRequestBody { + uid: string + user: UserAdditionalInfo +} + +export interface SelfRequestModel { + user?: UserRecord +} diff --git a/server/src/service-layer/response-models/UserResponse.ts b/server/src/service-layer/response-models/UserResponse.ts index 575cc8c84..bb00f9010 100644 --- a/server/src/service-layer/response-models/UserResponse.ts +++ b/server/src/service-layer/response-models/UserResponse.ts @@ -1,5 +1,4 @@ +import { FirebaseProperties } from "data-layer/models/common" import { UserAdditionalInfo } from "data-layer/models/firebase" -export type User = Omit & { - date_of_birth: Date -} +export type UserResponse = UserAdditionalInfo & FirebaseProperties diff --git a/server/tooling/login-prod.ts b/server/tooling/login-prod.ts new file mode 100644 index 000000000..4cfa0ae8f --- /dev/null +++ b/server/tooling/login-prod.ts @@ -0,0 +1,57 @@ +import admin from "firebase-admin" +import dotenv from "dotenv" +dotenv.config() + +/** + * Credit John Chen + * + * How to use: + * ``` + * ts-node ./test/scripts/loginScript + * + * ts-node ./test/scripts/loginScript mdLy2GYwTMZovNtnkj121dWU2YP2 + * ``` + */ + +admin.initializeApp({ + credential: admin.credential.applicationDefault() +}) + +const createIdToken = async (uid: string) => { + try { + const customToken = await admin.auth().createCustomToken(uid) + + const res = await fetch( + `https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyCustomToken?key=${process.env.API_KEY}`, + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: customToken, + returnSecureToken: true + }) + } + ) + + const data = (await res.json()) as any + console.log("\nAuthorization Header:") + console.log(data.idToken) + + return data.idToken + } catch (e) { + console.log(e) + } +} + +const args = process.argv.slice(2) + +if (args.length === 0) { + console.log("Login with User ID:", process.env.USER_ID) + createIdToken(process.env.USER_ID) +} else { + console.log("Login with User ID:", args[0]) + createIdToken(args[0]) +} diff --git a/server/tsconfig.json b/server/tsconfig.json index dca5e6477..fcb44e2e9 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -17,8 +17,9 @@ "resolveJsonModule": true }, "include": [ + "tooling/**/*", "src/**/*", - "src/business-layer/security/FirebaseAuth.ts", + "src/business-layer/security/Firebase.ts", "src/data-layer/adapters/EmulatorConfig.ts" ], "exclude": ["node_modules", "dist"] diff --git a/server/tsconfig.prod.json b/server/tsconfig.prod.json index e78cdedd9..53153162d 100644 --- a/server/tsconfig.prod.json +++ b/server/tsconfig.prod.json @@ -4,5 +4,5 @@ }, "extends": "./tsconfig.json", "include": ["src/middleware/__generated__/routes.ts"], - "exclude": ["**/*.test.ts", "**/*.mock.ts", "src/test-config"] + "exclude": ["**/*.test.ts", "**/*.mock.ts", "src/test-config", "tooling/**/*"] } diff --git a/yarn.lock b/yarn.lock index df6588e55..855ccf56d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7530,7 +7530,7 @@ firebase-tools@^12.4.7: winston-transport "^4.4.0" ws "^7.2.3" -firebase@^10.3.1, firebase@^10.8.1: +firebase@^10.3.1: version "10.8.1" resolved "https://registry.yarnpkg.com/firebase/-/firebase-10.8.1.tgz#d7eee67129a35fcfabda0c125e6b94abb9c420fb" integrity sha512-4B2jzhU/aumfKL446MG41/T5+t+9d9urf5XGrjC0HRQUm4Ya/amV48HBchnje69ExaJP5f2WxO9OX3wh9ee4wA==