From afa618e1d47b6f9d399633a583dbf466a36c182c Mon Sep 17 00:00:00 2001 From: tylerthome Date: Tue, 27 Aug 2024 03:00:46 -0700 Subject: [PATCH] use db data for coordinator dashboard, initial admin view --- .devcontainer/Dockerfile | 44 ++ .devcontainer/devcontainer.json | 27 ++ api/.devcontainer/Dockerfile | 32 ++ api/.devcontainer/devcontainer.json | 25 + .../controllers/auth_controller.py | 17 +- .../controllers/coordinator_controller.py | 71 +++ .../controllers/users_controller.py | 55 ++- api/openapi_server/models/database.py | 14 + api/openapi_server/models/schema.py | 16 +- api/openapi_server/models/user_roles.py | 8 +- api/openapi_server/openapi/openapi.yaml | 45 ++ api/openapi_server/openapi/paths/users.yaml | 3 +- .../openapi/schemas/_index.yaml | 7 + api/openapi_server/repositories/user_repo.py | 31 +- app/.devcontainer/Dockerfile | 26 ++ app/.devcontainer/devcontainer.json | 26 ++ app/.env.devcontainer | 3 + app/openapi-config.ts | 2 +- app/package.json | 1 + app/src/main.tsx | 9 + app/src/services/coordinator.ts | 58 ++- app/src/views/CoordinatorDashboard.tsx | 74 +-- app/src/views/SystemAdminDashboard.tsx | 437 ++++++++++++++++++ app/vite.config.ts | 4 +- scripts/install-deps-debian.bash | 11 + 25 files changed, 995 insertions(+), 51 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 api/.devcontainer/Dockerfile create mode 100644 api/.devcontainer/devcontainer.json create mode 100644 api/openapi_server/controllers/coordinator_controller.py create mode 100644 app/.devcontainer/Dockerfile create mode 100644 app/.devcontainer/devcontainer.json create mode 100644 app/.env.devcontainer create mode 100644 app/src/views/SystemAdminDashboard.tsx create mode 100644 scripts/install-deps-debian.bash diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..44a4bae8 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,44 @@ +# using a generic base image since we want to install python, nodejs, etc +FROM mcr.microsoft.com/vscode/devcontainers/base:dev-bullseye + +# use latest available system package listings and installations +RUN sudo apt-get update -y && sudo apt-get upgrade -y + +# we need `curl` to download things, and `build-essential` to +# install python and node from source +RUN sudo apt-get install -y \ + curl \ + build-essential \ + libsqlite3-dev \ + libpq-dev + +# these packages currently using 3.9 as latest available, +# ideally these would be included in the `apt-get` command: +# - python3 +# - python3-dev + +# keep dependency installation resources separate from source +WORKDIR /opt + +# download and install python from source +# as of this writing, the latest package supported by apt is Python 3.9, and +# this is the simplest way to get Python 3.12+ installed for dev +RUN curl -LO https://www.python.org/ftp/python/3.12.5/Python-3.12.5.tgz \ + && tar xzf Python-3.12.5.tgz \ + && cd Python-3.12.5 \ + && ./configure --enable-loadable-sqlite-extensions \ + && make \ + && sudo make install \ + && cd .. + +# intall nodejs from prebuilt binaries +# this approach avoids an issue with other provided installation steps +# using Node/Vercel tools and scripts, namely surrounding the execution +# of `source` and `.` commands during a Docker build +RUN curl -LO https://nodejs.org/dist/v20.17.0/node-v20.17.0-linux-x64.tar.xz \ + && tar xJf node-v20.17.0-linux-x64.tar.xz \ + && cd node-v20.17.0-linux-x64 \ + && cp -r ./bin/* /usr/local/bin/ \ + && cp -r ./lib/* /usr/local/lib/ \ + && cp -r ./share/* /usr/local/share/ \ + && cd .. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..ca8967e0 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,27 @@ +{ + "name": "FullStack", + "build": { + "dockerfile": "./Dockerfile", + "context": ".." + }, + // install the decalred pip and npm packages + "postCreateCommand": "bash ./scripts/install-deps-debian.bash", + "runArgs": ["--platform=linux/amd64"], + "forwardPorts": [ + 38429, + 38428 + ], + "customizations": { + "vscode": { + "settings": { + "terminal.integrated.defaultProfile.linux": "bash", + "extensions.verifySignature": false + }, + "extensions": [ + "ms-python.vscode-pylance", + "ms-vscode.vscode-typescript-next" + ] + } + } + } + \ No newline at end of file diff --git a/api/.devcontainer/Dockerfile b/api/.devcontainer/Dockerfile new file mode 100644 index 00000000..bf55da6e --- /dev/null +++ b/api/.devcontainer/Dockerfile @@ -0,0 +1,32 @@ +# using a generic base image since we want to install python, nodejs, etc +FROM mcr.microsoft.com/vscode/devcontainers/base:dev-bullseye + +# use latest available system package listings and installations +RUN sudo apt-get update -y && sudo apt-get upgrade -y + +# we need `curl` to download things, and `build-essential` to +# install python and node from source +RUN sudo apt-get install -y \ + curl \ + build-essential \ + libsqlite3-dev \ + libpq-dev + +# these packages currently using 3.9 as latest available, +# ideally these would be included in the `apt-get` command: +# - python3 +# - python3-dev + +# keep dependency installation resources separate from source +WORKDIR /opt + +# download and install python from source +# as of this writing, the latest package supported by apt is Python 3.9, and +# this is the simplest way to get Python 3.12+ installed for dev +RUN curl -LO https://www.python.org/ftp/python/3.12.5/Python-3.12.5.tgz \ + && tar xzf Python-3.12.5.tgz \ + && cd Python-3.12.5 \ + && ./configure --enable-loadable-sqlite-extensions \ + && make \ + && sudo make install \ + && cd .. diff --git a/api/.devcontainer/devcontainer.json b/api/.devcontainer/devcontainer.json new file mode 100644 index 00000000..2600c661 --- /dev/null +++ b/api/.devcontainer/devcontainer.json @@ -0,0 +1,25 @@ +{ + "name": "Python", + "build": { + "dockerfile": "./Dockerfile", + "context": ".." + }, + "postCreateCommand": "python3 -m pip install .", + //// TODO: if we can use the latest Python offered in devcontainers, this may + //// provide a better dev UX + // "image": "mcr.microsoft.com/vscode/devcontainers/python:3.9-bullseye", + "runArgs": ["--platform=linux/amd64"], + "forwardPorts": [ + 38429 + ], + "customizations": { + "vscode": { + "settings": { + "terminal.integrated.defaultProfile.linux": "bash", + "extensions.verifySignature": false + }, + "extensions": ["ms-python.vscode-pylance"] + } + } + } + \ No newline at end of file diff --git a/api/openapi_server/controllers/auth_controller.py b/api/openapi_server/controllers/auth_controller.py index 7812d399..6d444788 100644 --- a/api/openapi_server/controllers/auth_controller.py +++ b/api/openapi_server/controllers/auth_controller.py @@ -3,6 +3,7 @@ import requests import jwt import random +import json from flask import ( redirect, @@ -12,7 +13,7 @@ ) from openapi_server.exceptions import AuthError from openapi_server.models.database import DataAccessLayer, User -from openapi_server.repositories.user_repo import UserRepository +from openapi_server.repositories.user_repo import UnmatchedCaseRepository, UserRepository from openapi_server.models.user_roles import UserRole from openapi_server.models.schema import user_schema from sqlalchemy import select @@ -515,7 +516,7 @@ def google(): def invite(): if connexion.request.is_json: - body = connexion.request.get_json() + body = connexion.request.get_json() # TODO: Possibly encrypt these passwords? numbers = '0123456789' @@ -548,6 +549,7 @@ def invite(): raise AuthError({"message": msg}, 500) try: + coordinator_email = session['username'] with DataAccessLayer.session() as db_session: user_repo = UserRepository(db_session) user_repo.add_user( @@ -557,8 +559,19 @@ def invite(): middleName=body.get('middleName', ''), lastName=body.get('lastName', '') ) + guest_id = user_repo.get_user_id(body['email']) + coordinator_id = user_repo.get_user_id(coordinator_email) + unmatched_case_repo = UnmatchedCaseRepository(db_session) + unmatched_case_repo.add_case( + guest_id=guest_id, + coordinator_id=coordinator_id + ) except Exception as error: raise AuthError({"message": str(error)}, 400) + + + + def confirm_invite(): diff --git a/api/openapi_server/controllers/coordinator_controller.py b/api/openapi_server/controllers/coordinator_controller.py new file mode 100644 index 00000000..622c4291 --- /dev/null +++ b/api/openapi_server/controllers/coordinator_controller.py @@ -0,0 +1,71 @@ +from flask import Response + +from openapi_server.models.database import DataAccessLayer +from openapi_server.models.schema import users_schema +from openapi_server.models.user_roles import UserRole +from openapi_server.repositories.user_repo import UserRepository, UnmatchedCaseRepository + +import json +""" + + userName: + type: string + caseStatus: + type: string + coordinatorName: + type: string + userType: + type: string + lastUpdated: + type: string + notes: + type: string +""" +def get_dashboard_data() -> Response: + with DataAccessLayer.session() as session: + user_repo = UserRepository(session) + coordinator_users_by_id = {x.id: x for x in user_repo.get_users_with_role(UserRole.COORDINATOR)} + print(f'get_dashboard_data(): coordinator_users_by_id = {json.dumps({k:v.email for k,v in coordinator_users_by_id.items()})}') + case_repo = UnmatchedCaseRepository(session) + + all_users = [] + for guest in user_repo.get_users_with_role(UserRole.GUEST): + print(f'get_dashboard_data(): looking at guest: {guest.email} with ID "{guest.id}"') + case_status = case_repo.get_case_for_guest(int(guest.id)) + print(f'get_dashboard_data(): get_case_for_guest({guest.id}) returned "{case_status}"') + coordinator = coordinator_users_by_id[case_status.coordinator_id] + all_users.append({ + 'id': guest.id, + 'userName': f'{guest.firstName} {guest.lastName}', + 'caseStatus': 'In Progress', + 'userType':'GUEST', + 'coordinatorName': f'{coordinator.firstName} {coordinator.lastName}', + 'lastUpdated': '2024-08-25', + 'Notes': 'N/A' + }) + + for host in user_repo.get_users_with_role(UserRole.HOST): + all_users.append({ + 'id': host.id, + 'userName': f'{host.firstName} {host.lastName}', + 'caseStatus': 'In Progress', + 'userType':'HOST', + 'coordinatorName': f'N/A', + 'lastUpdated': '2024-08-25', + 'Notes': 'N/A' + }) + + for coordinator in user_repo.get_users_with_role(UserRole.COORDINATOR): + all_users.append({ + 'id': coordinator.id, + 'userName': f'{coordinator.firstName} {coordinator.lastName}', + 'caseStatus': 'N/A', + 'userType':'COORDINATOR', + 'coordinatorName': f'N/A', + 'lastUpdated': '2024-08-25', + 'Notes': 'N/A' + }) + + return { + 'dashboardItems': all_users + }, 200 diff --git a/api/openapi_server/controllers/users_controller.py b/api/openapi_server/controllers/users_controller.py index 21d1ccd8..e166e43a 100644 --- a/api/openapi_server/controllers/users_controller.py +++ b/api/openapi_server/controllers/users_controller.py @@ -1,6 +1,4 @@ -import string - from openapi_server.controllers.auth_controller import get_token_auth_header from openapi_server.models.database import DataAccessLayer, User @@ -8,15 +6,58 @@ from sqlalchemy import delete from sqlalchemy.exc import IntegrityError from openapi_server.exceptions import AuthError +from openapi_server.repositories.user_repo import UnmatchedCaseRepository, UserRepository + +from openapi_server.models.database import Role, UnmatchedGuestCase +from openapi_server.models.user_roles import UserRole + +def delete_user(user_id: int): + + # get the user's username (i.e. email) from db + with DataAccessLayer.session() as db_session: + try: + user_repo = UserRepository(db_session) + user: User = user_repo.get_user_by_id(user_id) + role = db_session.query(Role).filter_by(id=user.role_id).first() + + if role.name == UserRole.GUEST.value: + unmatched_cases_repo = UnmatchedCaseRepository(db_session) + unmatched_cases_repo.delete_case_for_guest(user.id) + + unmatched_cases = [] + if role.name == UserRole.COORDINATOR.value: + unmatched_cases = db_session.query(UnmatchedGuestCase).filter_by(coordinator_id=user.id).all() + -def delete_user(user_id: string): - # get access token from header - access_token = get_token_auth_header() + if len(unmatched_cases) > 0: + user_repo = UserRepository(db_session) + guests_by_id = {x.id: x for x in user_repo.get_users_with_role(UserRole.GUEST)} + guest_emails_with_ids = [{ + 'id': x.guest_id, + 'email': guests_by_id[x.guest_id].email, + } for x in unmatched_cases] + + guest_emails_with_ids_strs = [f'{g["email"]} (#{g["id"]})' for g in guest_emails_with_ids] + + return { + "message": f"Coordinator is associated with {len(unmatched_cases)} case(s). Move these Guest(s) to a different Coordinator before attempting to delete this account", + "items":guest_emails_with_ids_strs + }, 400 + + except AuthError as auth_error: + raise auth_error + except IntegrityError: + db_session.rollback() + raise AuthError({ + "message": "An error occured while removing user to database." + }, 422) + # delete user from cognito try: - response = current_app.boto_client.delete_user( - AccessToken=access_token + response = current_app.boto_client.admin_delete_user( + UserPoolId=current_app.config['COGNITO_USER_POOL_ID'], + Username=user.email ) except Exception as e: code = e.response['Error']['Code'] diff --git a/api/openapi_server/models/database.py b/api/openapi_server/models/database.py index 4395a5f4..f8fe9664 100644 --- a/api/openapi_server/models/database.py +++ b/api/openapi_server/models/database.py @@ -28,6 +28,20 @@ def validate_first_name(self, key, value): raise ValueError(f"{key} must contain at least one non-space character") return value.strip() +class UnmatchedGuestCase(Base): + __tablename__ = "unmatched_guest_case" + id = Column(Integer, primary_key=True, index=True) + guest_id = Column(Integer, ForeignKey('user.id'), nullable=False) + coordinator_id = Column(Integer, ForeignKey('user.id'), nullable=False) + status_id = Column(Integer, ForeignKey('unmatched_guest_case_status.id'), nullable=False) + status = relationship("UnmatchedGuestCaseStatus", back_populates="cases") + +class UnmatchedGuestCaseStatus(Base): + __tablename__ = "unmatched_guest_case_status" + id = Column(Integer, primary_key=True, index=True) + status_text = Column(String(255), nullable=False, unique=True) + cases = relationship("UnmatchedGuestCase", back_populates="status") + class Role(Base): __tablename__ = "role" id = Column(Integer, primary_key=True, index=True) diff --git a/api/openapi_server/models/schema.py b/api/openapi_server/models/schema.py index 2757db20..5f316c94 100644 --- a/api/openapi_server/models/schema.py +++ b/api/openapi_server/models/schema.py @@ -31,6 +31,18 @@ class Meta: include_relationships = True load_instance = True +class UnmatchedCaseSchema(SQLAlchemyAutoSchema): + class Meta: + model = UnmatchedGuestCase + include_relationships = True + load_instance = True + +class UnmatchedCaseStatusSchema(SQLAlchemyAutoSchema): + class Meta: + model = UnmatchedGuestCaseStatus + include_relationships = True + load_instance = True + class UserSchema(SQLAlchemyAutoSchema): role = Nested(RoleSchema, only=['name'], required=True) class Meta: @@ -146,4 +158,6 @@ def make_response(self, data, **kwargs): service_provider_schema = HousingProgramServiceProviderSchema() service_provider_list_schema = HousingProgramServiceProviderSchema(many=True) form_schema = FormSchema() -response_schema = ResponseSchema(many=True) \ No newline at end of file +response_schema = ResponseSchema(many=True) +unmatched_cs_schema = UnmatchedCaseStatusSchema() +unmatched_c_schema = UnmatchedCaseSchema() \ No newline at end of file diff --git a/api/openapi_server/models/user_roles.py b/api/openapi_server/models/user_roles.py index 21f19273..105ca251 100644 --- a/api/openapi_server/models/user_roles.py +++ b/api/openapi_server/models/user_roles.py @@ -4,4 +4,10 @@ class UserRole(Enum): ADMIN = "Admin" GUEST = "Guest" HOST = "Host" - COORDINATOR = "Coordinator" \ No newline at end of file + COORDINATOR = "Coordinator" + + + +class UmatchedCaseStatus(Enum): + IN_PROGRESS = "In Progress" + COMPLETE = "Complete" \ No newline at end of file diff --git a/api/openapi_server/openapi/openapi.yaml b/api/openapi_server/openapi/openapi.yaml index 213e34f9..cabc74cd 100644 --- a/api/openapi_server/openapi/openapi.yaml +++ b/api/openapi_server/openapi/openapi.yaml @@ -55,6 +55,24 @@ paths: $ref: "./paths/forms.yaml" /responses/{form_id}: $ref: "./paths/responses.yaml" + /coordinator/dashboard/all: + get: + summary: Get dashboard data + operationId: get_dashboard_data + responses: + '200': + description: Successfully retrieved responses + content: + application/json: + schema: + $ref: '#/components/schemas/DashboardData' + '401': + description: Authentication error + '404': + description: Responses not found + x-openapi-router-controller: openapi_server.controllers.coordinator_controller + security: + - jwt: ["secret"] /health: get: operationId: openapi_server.controllers.admin_controller.health @@ -76,6 +94,33 @@ components: $ref: "./parameters/_index.yaml" schemas: $ref: "./schemas/_index.yaml" + DashboardDataItem: + properties: + id: + format: int64 + type: integer + userName: + type: string + caseStatus: + type: string + coordinatorName: + type: string + userType: + type: string + lastUpdated: + type: string + notes: + type: string + title: DashboardDataItem + type: object + DashboardData: + properties: + dashboardItems: + type: array + items: + $ref: '#/components/schemas/DashboardDataItem' + title: DashboardData + type: object ApiResponse: example: code: 0 diff --git a/api/openapi_server/openapi/paths/users.yaml b/api/openapi_server/openapi/paths/users.yaml index 5cee661d..09044cc3 100644 --- a/api/openapi_server/openapi/paths/users.yaml +++ b/api/openapi_server/openapi/paths/users.yaml @@ -9,7 +9,8 @@ delete: required: true description: The id of the user to delete schema: - type: string + format: int64 + type: integer responses: "200": description: Null response diff --git a/api/openapi_server/openapi/schemas/_index.yaml b/api/openapi_server/openapi/schemas/_index.yaml index 5f7185ae..b5917c15 100644 --- a/api/openapi_server/openapi/schemas/_index.yaml +++ b/api/openapi_server/openapi/schemas/_index.yaml @@ -49,6 +49,13 @@ ServiceProviders: type: array items: $ref: '#/ServiceProviderWithId' +DashboardDataSchema: + type: object + properties: + name: + type: string + required: + - name RoleSchema: type: object properties: diff --git a/api/openapi_server/repositories/user_repo.py b/api/openapi_server/repositories/user_repo.py index 5904396e..dff00391 100644 --- a/api/openapi_server/repositories/user_repo.py +++ b/api/openapi_server/repositories/user_repo.py @@ -1,7 +1,31 @@ from typing import List -from openapi_server.models.database import User, Role -from openapi_server.models.user_roles import UserRole +from openapi_server.models.database import UnmatchedGuestCase, UnmatchedGuestCaseStatus, User, Role +from openapi_server.models.user_roles import UmatchedCaseStatus, UserRole + +class UnmatchedCaseRepository: + def __init__(self, session): + self.session = session + + def add_case(self, guest_id: int, coordinator_id: int) -> UnmatchedGuestCase: + status_id = self.session.query(UnmatchedGuestCaseStatus).filter_by(status_text=UmatchedCaseStatus.IN_PROGRESS).first().id + new_guest_case = UnmatchedGuestCase(guest_id=guest_id,coordinator_id=coordinator_id,status_id=status_id) + self.session.add(new_guest_case) + self.session.commit() + + return new_guest_case + + + def delete_case_for_guest(self, guest_id: int) -> bool: + guest_case = self.session.query(UnmatchedGuestCaseStatus).filter_by(guest_id=guest_id).first() + if guest_case: + self.session.delete(guest_case) + self.session.commit() + return True + return False + + def get_case_for_guest(self, guest_id: int) -> UnmatchedGuestCase: + return self.session.query(UnmatchedGuestCase).filter_by(guest_id=guest_id).first() class UserRepository: def __init__(self, session): @@ -30,6 +54,9 @@ def delete_user(self, user_id: int) -> bool: return True return False + def get_user_by_id(self, id: int) -> User: + return self.session.query(User).filter_by(id=id).first() + def get_user(self, email: str) -> User: return self.session.query(User).filter_by(email=email).first() diff --git a/app/.devcontainer/Dockerfile b/app/.devcontainer/Dockerfile new file mode 100644 index 00000000..f8a11173 --- /dev/null +++ b/app/.devcontainer/Dockerfile @@ -0,0 +1,26 @@ +# using a generic base image since we want to install python, nodejs, etc +FROM mcr.microsoft.com/vscode/devcontainers/base:dev-bullseye + +# use latest available system package listings and installations +RUN sudo apt-get update -y && sudo apt-get upgrade -y + +# we need `curl` to download things, and `build-essential` to +# install python and node from source +RUN sudo apt-get install -y \ + curl \ + build-essential + +# keep dependency installation resources separate from source +WORKDIR /opt + +# intall nodejs from prebuilt binaries +# this approach avoids an issue with other provided installation steps +# using Node/Vercel tools and scripts, namely surrounding the execution +# of `source` and `.` commands during a Docker build +RUN curl -LO https://nodejs.org/dist/v20.17.0/node-v20.17.0-linux-x64.tar.xz \ + && tar xJf node-v20.17.0-linux-x64.tar.xz \ + && cd node-v20.17.0-linux-x64 \ + && cp -r ./bin/* /usr/local/bin/ \ + && cp -r ./lib/* /usr/local/lib/ \ + && cp -r ./share/* /usr/local/share/ \ + && cd .. diff --git a/app/.devcontainer/devcontainer.json b/app/.devcontainer/devcontainer.json new file mode 100644 index 00000000..548f1db3 --- /dev/null +++ b/app/.devcontainer/devcontainer.json @@ -0,0 +1,26 @@ +{ + "name": "Node", + "build": { + "dockerfile": "./Dockerfile", + "context": ".." + }, + // install the decalred pip and npm packages + "postCreateCommand": "npm install", + "runArgs": ["--platform=linux/amd64"], + "forwardPorts": [ + 38428 + ], + "customizations": { + "vscode": { + "settings": { + "terminal.integrated.defaultProfile.linux": "bash", + "extensions.verifySignature": false + }, + "extensions": [ + "ms-python.vscode-pylance", + "ms-vscode.vscode-typescript-next" + ] + } + } + } + \ No newline at end of file diff --git a/app/.env.devcontainer b/app/.env.devcontainer new file mode 100644 index 00000000..69f6768a --- /dev/null +++ b/app/.env.devcontainer @@ -0,0 +1,3 @@ +VITE_COGNITO_CLIENT_ID= +VITE_COGNITO_REDIRECT_URI=http://localhost:38428/signin +VITE_HUU_API_BASE_URL=http://localhost:38429/api/ diff --git a/app/openapi-config.ts b/app/openapi-config.ts index a4075f03..2bd907f7 100644 --- a/app/openapi-config.ts +++ b/app/openapi-config.ts @@ -1,7 +1,7 @@ import type {ConfigFile} from '@rtk-query/codegen-openapi'; const config: ConfigFile = { - schemaFile: 'http://localhost:8080/api/openapi.json', + schemaFile: 'http://localhost:38429/api/openapi.json', apiFile: './src/services/api.ts', apiImport: 'api', outputFiles: { diff --git a/app/package.json b/app/package.json index b768b5c9..fc81e858 100644 --- a/app/package.json +++ b/app/package.json @@ -8,6 +8,7 @@ }, "scripts": { "dev": "vite", + "dbg": "vite --debug", "build": "tsc && vite build", "preview": "vite preview", "test": "vitest", diff --git a/app/src/main.tsx b/app/src/main.tsx index 8b3c2544..fc3e7555 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -43,6 +43,7 @@ import { } from './components/layout'; import {FieldGroupList} from './components/intake-profile/IntakeProfileGroups'; import {enableMocking} from './utils/test/browser'; +import {SystemAdminDashboard} from './views/SystemAdminDashboard'; function HuuApp() { const [session] = useSessionMutation(); @@ -128,6 +129,14 @@ function HuuApp() { } /> + + + + } + /> ); diff --git a/app/src/services/coordinator.ts b/app/src/services/coordinator.ts index ee27fb6b..0429d9bf 100644 --- a/app/src/services/coordinator.ts +++ b/app/src/services/coordinator.ts @@ -1,22 +1,74 @@ import {api} from './api'; +/** Specifies information needed to invite a new `Guest` user to create an account */ export interface InviteGuestRequest { firstName: string; lastName: string; email: string; } +/** Display data for a single user within the `CoordinatorDashboard` module */ +export interface DashboardDataItem { + id: number; + userId: string; + userName: string; + caseStatus: string; + coordinatorName: string; + userType: 'GUEST' | 'HOST' | 'COORDINATOR'; + lastUpdated: string; + notes: string; +} + +/** Error model for admin-level API responses */ +export interface AdminApiError { + message: string; + items: string[]; +} + +/** A container class for all data relevant to initial `CoordinatorDashboard` display components */ +export interface DashboardDataResponse { + dashboardItems: Array; +} + const coordinatorApi = api.injectEndpoints({ endpoints: build => ({ + /** + * Initiates the invite process for a `Guest` on behalf of a `Coordinator` or `Admin` + **/ inviteGuest: build.mutation({ - query: credentials => ({ + query: (req: InviteGuestRequest) => ({ url: 'auth/invite', method: 'POST', withCredentials: true, - body: credentials, + body: req, + }), + }), + /** + * Fetch all registered `Guest`, `Host` and `Coordinator` users + **/ + getAllDashboardData: build.mutation({ + query: () => ({ + url: 'coordinator/dashboard/all', + method: 'GET', + withCredentials: true, + }), + }), + /** + * Delete a user entirely from the system. This is an + * admin-only method intended for debugging purposes only + **/ + adminDeleteUser: build.mutation({ + query: userId => ({ + url: `/users/${userId}`, + method: 'DELETE', + withCredentials: true, }), }), }), }); -export const {useInviteGuestMutation} = coordinatorApi; +export const { + useInviteGuestMutation, + useGetAllDashboardDataMutation, + useAdminDeleteUserMutation, +} = coordinatorApi; diff --git a/app/src/views/CoordinatorDashboard.tsx b/app/src/views/CoordinatorDashboard.tsx index 7ba83cf9..b7a5363a 100644 --- a/app/src/views/CoordinatorDashboard.tsx +++ b/app/src/views/CoordinatorDashboard.tsx @@ -1,46 +1,35 @@ -import {useState} from 'react'; +import {useEffect, useState} from 'react'; import {Box, Tabs, Tab, Typography, Pagination, Stack} from '@mui/material'; import { DataGrid, - GridRowsProp, GridColDef, gridPageCountSelector, gridPageSelector, useGridApiContext, useGridSelector, } from '@mui/x-data-grid'; -import {faker} from '@faker-js/faker'; +// import {faker} from '@faker-js/faker'; import {styled} from '@mui/material/styles'; +import { + AdminApiError, + DashboardDataItem, + DashboardDataResponse, + useGetAllDashboardDataMutation, +} from '../services/coordinator'; import {GuestInviteButton} from '../components/common'; import {LoadingComponent} from './LoadingComponent'; -const buildRow = () => { - return { - id: faker.string.uuid(), - applicant: faker.person.fullName(), - type: faker.helpers.arrayElement(['Guest', 'Host']), - status: faker.helpers.arrayElement(['In Progress', 'Complete']), - coordinator: faker.helpers.arrayElement([ - faker.person.fullName(), - 'Unassigned', - ]), - updated: faker.date.past().toLocaleDateString(), - notes: faker.lorem.words({min: 0, max: 15}), - }; -}; -const rows: GridRowsProp = Array.from(Array(30), () => buildRow()); - const columns: GridColDef[] = [ { - field: 'applicant', + field: 'userName', headerName: 'Applicant', flex: 1, }, - {field: 'type', headerName: 'Type'}, - {field: 'status', headerName: 'Status'}, - {field: 'coordinator', headerName: 'Coordinator', flex: 1}, - {field: 'updated', headerName: 'Updated', flex: 1}, + {field: 'userType', headerName: 'Type'}, + {field: 'caseStatus', headerName: 'Status'}, + {field: 'coordinatorName', headerName: 'Coordinator', flex: 1}, + {field: 'lastUpdated', headerName: 'Updated', flex: 1}, { field: 'notes', headerName: 'Notes', @@ -58,24 +47,47 @@ function a11yProps(index: number) { export const CoordinatorDashboard = () => { const [value, setValue] = useState(0); + const [dashboardDataItems, setDashboardDataItems] = useState( + [] as DashboardDataItem[], + ); + // const [getAllDashboardData, {isLoading, isSuccess, reset}] = useGetAllDashboardDataMutation(); + const [getAllDashboardData] = useGetAllDashboardDataMutation(); - const data = rows.filter(row => { + const dashboardData = dashboardDataItems.filter(row => { if (value === 0) { return row; } else if (value === 1) { - return row.type === 'Guest'; + return row.userType === 'GUEST'; } else if (value === 2) { - return row.type === 'Host'; + return row.userType === 'HOST'; } }); - const totalGuests = rows.filter(row => row.type === 'Guest').length; - const totalHosts = rows.filter(row => row.type === 'Host').length; + const totalAppUsers = dashboardDataItems.filter( + row => ['GUEST', 'HOST'].indexOf(row.userType) >= 0, + ).length; + const totalGuests = dashboardDataItems.filter( + row => row.userType === 'GUEST', + ).length; + const totalHosts = dashboardDataItems.filter( + row => row.userType === 'HOST', + ).length; const handleChange = (event: React.SyntheticEvent, newValue: number) => { setValue(newValue); }; + useEffect(() => { + getAllDashboardData() + .unwrap() + .then((dashboardDataResponse: DashboardDataResponse) => { + setDashboardDataItems(dashboardDataResponse.dashboardItems); + }) + .catch((reason: {data: AdminApiError}) => { + console.log(`getAllDashboardData failed: ${JSON.stringify(reason)}`); + }); + }, [value]); + return ( { icon={ - {rows.length} + {totalAppUsers} } @@ -147,7 +159,7 @@ export const CoordinatorDashboard = () => { ; + userDeletion: { + status: UserDeletionStatus; + userId: number; + }; + userError: { + hasUnackedError: boolean; + apiError: AdminApiError; + }; +} + +enum SystemAdminDashboardActionType { + RefreshDashboardItems, + StartDeleteUser, + CancelDeleteUser, + ConfirmDeleteUser, + FinishDeleteUser, + DisplayErrorMessage, + AckErrorMessage, +} + +interface SystemAdminDashboardAction { + type: SystemAdminDashboardActionType; + payload?: string | number | Array | AdminApiError; +} + +enum UserDeletionStatus { + Ready, + Confirming, + Processing, + DeleteCompleted, +} + +const initialState: SystemAdminDashboardState = { + rows: [], + userDeletion: { + status: UserDeletionStatus.Ready, + userId: -1, + }, + userError: { + hasUnackedError: false, + apiError: { + message: '-', + items: [], + } as AdminApiError, + }, +}; + +function reducer( + state: SystemAdminDashboardState, + action: SystemAdminDashboardAction, +): SystemAdminDashboardState { + switch (action.type) { + case SystemAdminDashboardActionType.RefreshDashboardItems: + return { + ...state, + userDeletion: { + status: UserDeletionStatus.Ready, + userId: -1, + }, + rows: action.payload as Array, + }; + + case SystemAdminDashboardActionType.StartDeleteUser: + return { + ...state, + userDeletion: { + status: UserDeletionStatus.Confirming, + userId: action.payload as number, + }, + }; + + case SystemAdminDashboardActionType.ConfirmDeleteUser: + return { + ...state, + userDeletion: { + ...state.userDeletion, + status: UserDeletionStatus.Processing, + }, + }; + + case SystemAdminDashboardActionType.CancelDeleteUser: + return { + ...state, + userDeletion: { + ...state.userDeletion, + userId: -1, + status: UserDeletionStatus.Ready, + }, + }; + + case SystemAdminDashboardActionType.FinishDeleteUser: + return { + ...state, + userDeletion: { + ...state.userDeletion, + userId: -1, + status: UserDeletionStatus.DeleteCompleted, + }, + }; + + case SystemAdminDashboardActionType.DisplayErrorMessage: + return { + ...state, + userError: { + ...state.userError, + hasUnackedError: true, + apiError: action.payload as AdminApiError, + }, + }; + + case SystemAdminDashboardActionType.AckErrorMessage: + return { + ...state, + userError: { + ...state.userError, + hasUnackedError: false, + apiError: { + message: '-', + items: [], + }, + }, + }; + + default: + throw new Error(`Unsupported action: ${JSON.stringify(action)}`); + } +} + +/** An internal-only console for sysadmin functions */ +export function SystemAdminDashboard(props: SystemAdminDashboardProps) { + const [state, dispatch] = React.useReducer(reducer, initialState); + // const [getAllDashboardData, { isLoading, isSuccess, reset }] = useGetAllDashboardDataMutation(); + const [getAllDashboardData] = useGetAllDashboardDataMutation(); + const [adminDeleteUser] = useAdminDeleteUserMutation(); + + const timer = React.useRef>(); + //// simulate long-running process using timeout + // timer.current = setTimeout(() => { + // dispatch({ + // type: SystemAdminDashboardActionType.FinishDeleteUser + // }); + // }, 2000); + + /** Close the modal and forget about deleting the selected user */ + const refreshDashboardData = () => { + getAllDashboardData() + .unwrap() + .then((dashboardDataResponse: DashboardDataResponse) => { + dispatch({ + type: SystemAdminDashboardActionType.RefreshDashboardItems, + payload: dashboardDataResponse.dashboardItems, + }); + }) + .catch((reason: {data: AdminApiError}) => { + displayErrorMessage({message: reason['data']['message'], items: []}); + }); + }; + + React.useEffect(() => { + refreshDashboardData(); + return () => { + clearTimeout(timer.current); + }; + }, []); + + React.useEffect(() => { + if (state.userDeletion.status === UserDeletionStatus.DeleteCompleted) { + refreshDashboardData(); + } + }, [state.userDeletion.status]); + + const buttonSx = { + ...(state.userDeletion.status === UserDeletionStatus.Confirming && { + bgcolor: red[500], + '&:hover': { + bgcolor: red[700], + }, + }), + }; + + /** Close the modal and forget about deleting the selected user */ + const handleConfirmDeleteModalClosed = () => { + dispatch({ + type: SystemAdminDashboardActionType.CancelDeleteUser, + }); + }; + + /** Attempts the actual user deletion via API request */ + const handleConfirmDelete = () => { + if (state.userDeletion.status !== UserDeletionStatus.Confirming) { + return; + } + dispatch({ + type: SystemAdminDashboardActionType.ConfirmDeleteUser, + }); + + adminDeleteUser(state.userDeletion.userId) + .unwrap() + .then(() => { + dispatch({ + type: SystemAdminDashboardActionType.FinishDeleteUser, + }); + }) + .catch((reason: {data: AdminApiError}) => { + dispatch({ + type: SystemAdminDashboardActionType.FinishDeleteUser, + }); + + displayErrorMessage(reason['data']); + }); + }; + + /** Closes dialog and resets error data */ + const handleErrorMessageAcked = () => { + dispatch({ + type: SystemAdminDashboardActionType.AckErrorMessage, + }); + }; + + /** Opens dialog and displays error data */ + const displayErrorMessage = (errorData: AdminApiError) => { + dispatch({ + type: SystemAdminDashboardActionType.DisplayErrorMessage, + payload: errorData, + }); + }; + + /** Initiates the deletion process by prompting user for confirmation */ + const startDelete = (userId: number) => { + dispatch({ + type: SystemAdminDashboardActionType.StartDeleteUser, + payload: userId, + }); + }; + + return ( + <> + + + + System Administrator Dashboard + + + + {/* */} +
+ + + ID + Name + Type + Coordinator + Update + Delete + + + + {state.rows.map(row => ( + + + {row.id} + + {row.userName} + {row.userType} + {row.coordinatorName} + + + + + + + + ))} + +
+
+
+
+ = 0 + } + > + Delete user {state.userDeletion.userId}? + + + This will remove the user from the Home Unite Us SQL database and + the user pool in Cognito + + + + + + + + + + {state.userDeletion.status === UserDeletionStatus.Processing && ( + + )} + + + + + + Error during delete + + + + {state.userError.apiError.message} +
+ + {state.userError.apiError.items.length > 0 && + state.userError.apiError.items.map( + (item: string, index: number) => ( + + + + + + + + + ), + )} + +
+
+
+
+ + + + + + + +
+ + ); +} diff --git a/app/vite.config.ts b/app/vite.config.ts index 33ca578c..9c8ecad2 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -11,7 +11,7 @@ function huuApiBaseUrl(envHuuApiBaseUrl: string, mode: string): URL | never { return new URL(envHuuApiBaseUrl); } catch { if (mode === 'development' || mode === 'test') { - return new URL('http://localhost:8080/api'); + return new URL('http://localhost:38429/api'); } else { throw new Error('VITE_HUU_API_BASE_URL is not configured with a URL'); } @@ -43,7 +43,7 @@ export default defineConfig(({mode}) => { }, plugins: [react()], server: { - port: 4040, + port: 38428, proxy: { '/api': { target: apiBaseUrl.origin, diff --git a/scripts/install-deps-debian.bash b/scripts/install-deps-debian.bash new file mode 100644 index 00000000..cc219e91 --- /dev/null +++ b/scripts/install-deps-debian.bash @@ -0,0 +1,11 @@ +#!/bin/bash +ls +echo "installing backend deps" +pushd ./api + python3 -m pip install . +popd + +echo "installing frontend deps" +pushd ./app + npm install +popd