diff --git a/README.md b/README.md index 8df768fe..c35af40b 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,18 @@ cd supportive-housing docker-compose up --build ``` +4. Run the initial migration: `bash ./scripts/flask-db-upgrade.sh` + +5. Create an Admin user. In `seeding/.env`, ensure `FIRST_NAME`, `LAST_NAME`, and `EMAIL` are set (you should use your Blueprint email/any email you have access to here). Ensure `ROLE` is set to `Admin`. Run: +```bash +bash ./seeding/invite-user.sh +``` +**IMPORTANT**: If you've reset your local DB and want to re-use an email, ensure it's deleted from Firebase as well (ask the PL for access if you don't have it) + +6. Signup for an account on the app! Ensure that you use the values you used in Step 3. Your password can be anything you remember + +7. Verify your email address. You should receive an email in your inbox with a link - once you click the link, you're good to freely use the app! You can invite any other users through the `Employee Directory` within the `Admin Controls` + ## Useful Commands ### Database Migration @@ -75,7 +87,6 @@ bash ./scripts/restart-docker.sh ### Seeding Before running these scripts, remember to update the `.env` file to ensure you're configuring your data to your needs: ```bash -bash ./seeding/create-user.sh # Create a user with a specific name and role bash ./seeding/create-residents.sh # Create a number of residents bash ./seeding/create-log-records.sh # Create a number of log records ``` diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b40e490d..f85fb4f8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -14,6 +14,7 @@ def init_app(app): from .log_record_tags import LogRecordTag from .residents import Residents from .buildings import Buildings + from .log_record_residents import LogRecordResidents app.app_context().push() db.init_app(app) diff --git a/backend/app/models/log_record_residents.py b/backend/app/models/log_record_residents.py new file mode 100644 index 00000000..2ea12f25 --- /dev/null +++ b/backend/app/models/log_record_residents.py @@ -0,0 +1,34 @@ +from sqlalchemy import inspect +from sqlalchemy.orm.properties import ColumnProperty + +from . import db + + +class LogRecordResidents(db.Model): + __tablename__ = "log_record_residents" + + id = db.Column(db.Integer, primary_key=True, nullable=False) + log_record_id = db.Column( + db.Integer, db.ForeignKey("log_records.log_id"), nullable=False + ) + resident_id = db.Column(db.Integer, db.ForeignKey("residents.id"), nullable=False) + + def to_dict(self, include_relationships=False): + # define the entities table + cls = type(self) + + mapper = inspect(cls) + formatted = {} + for column in mapper.attrs: + field = column.key + attr = getattr(self, field) + # if it's a regular column, extract the value + if isinstance(column, ColumnProperty): + formatted[field] = attr + # otherwise, it's a relationship field + # (currently not applicable, but may be useful for entity groups) + elif include_relationships: + # recursively format the relationship + # don't format the relationship's relationships + formatted[field] = [obj.to_dict() for obj in attr] + return formatted diff --git a/backend/app/models/log_record_tags.py b/backend/app/models/log_record_tags.py index e052e234..30f5ea6f 100644 --- a/backend/app/models/log_record_tags.py +++ b/backend/app/models/log_record_tags.py @@ -11,7 +11,9 @@ class LogRecordTag(db.Model): log_record_id = db.Column( db.Integer, db.ForeignKey("log_records.log_id"), nullable=False ) - tag_id = db.Column(db.Integer, db.ForeignKey("tags.tag_id"), nullable=False) + tag_id = db.Column( + db.Integer, db.ForeignKey("tags.tag_id", ondelete="CASCADE"), nullable=False + ) def to_dict(self, include_relationships=False): # define the entities table diff --git a/backend/app/models/log_records.py b/backend/app/models/log_records.py index aab98794..2243a524 100644 --- a/backend/app/models/log_records.py +++ b/backend/app/models/log_records.py @@ -7,7 +7,6 @@ class LogRecords(db.Model): __tablename__ = "log_records" log_id = db.Column(db.Integer, primary_key=True, nullable=False) employee_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) - resident_id = db.Column(db.Integer, db.ForeignKey("residents.id"), nullable=False) datetime = db.Column(db.DateTime(timezone=True), nullable=False) flagged = db.Column(db.Boolean, nullable=False) attn_to = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) @@ -17,6 +16,9 @@ class LogRecords(db.Model): tags = db.relationship( "Tag", secondary="log_record_tag", back_populates="log_records" ) + residents = db.relationship( + "Residents", secondary="log_record_residents", back_populates="log_records" + ) building = db.relationship("Buildings", back_populates="log_record") def to_dict(self, include_relationships=False): diff --git a/backend/app/models/residents.py b/backend/app/models/residents.py index 8e2bbc3a..51611c42 100644 --- a/backend/app/models/residents.py +++ b/backend/app/models/residents.py @@ -12,6 +12,9 @@ class Residents(db.Model): date_left = db.Column(db.Date, nullable=True) building_id = db.Column(db.Integer, db.ForeignKey("buildings.id"), nullable=False) building = db.relationship("Buildings", back_populates="resident") + log_records = db.relationship( + "LogRecords", secondary="log_record_residents", back_populates="residents" + ) resident_id = db.column_property(initial + cast(room_num, String)) diff --git a/backend/app/models/tags.py b/backend/app/models/tags.py index 18a6194d..6340b312 100644 --- a/backend/app/models/tags.py +++ b/backend/app/models/tags.py @@ -8,8 +8,13 @@ class Tag(db.Model): __tablename__ = "tags" tag_id = db.Column(db.Integer, primary_key=True, nullable=False) - name = db.Column(db.String, nullable=False) - status = db.Column(db.Enum("Deleted", "Active", name="status"), nullable=False) + name = db.Column(db.String, unique=True, nullable=False) + last_modified = db.Column( + db.DateTime, + server_default=db.func.now(), + onupdate=db.func.now(), + nullable=False, + ) log_records = db.relationship( "LogRecords", secondary="log_record_tag", back_populates="tags" ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 5fb184f7..161f4f4e 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,4 +1,4 @@ -from sqlalchemy import inspect, case, null +from sqlalchemy import func, inspect, case, null from sqlalchemy.orm.properties import ColumnProperty from . import db @@ -19,6 +19,9 @@ class User(db.Model): nullable=False, ) email = db.Column(db.String, nullable=False) + last_modified = db.Column( + db.DateTime, server_default=func.now(), onupdate=func.now(), nullable=False + ) __table_args__ = ( db.CheckConstraint( diff --git a/backend/app/resources/auth_dto.py b/backend/app/resources/auth_dto.py index a1bb86eb..609e5e25 100644 --- a/backend/app/resources/auth_dto.py +++ b/backend/app/resources/auth_dto.py @@ -13,6 +13,9 @@ def __init__( email, role, user_status, + last_modified, ): Token.__init__(self, access_token, refresh_token) - UserDTO.__init__(self, id, first_name, last_name, email, role, user_status) + UserDTO.__init__( + self, id, first_name, last_name, email, role, user_status, last_modified + ) diff --git a/backend/app/resources/user_dto.py b/backend/app/resources/user_dto.py index a15e6279..559423bb 100644 --- a/backend/app/resources/user_dto.py +++ b/backend/app/resources/user_dto.py @@ -1,8 +1,11 @@ class UserDTO: - def __init__(self, id, first_name, last_name, email, role, user_status): + def __init__( + self, id, first_name, last_name, email, role, user_status, last_modified + ): self.id = id self.first_name = first_name self.last_name = last_name self.email = email self.role = role self.user_status = user_status + self.last_modified = last_modified diff --git a/backend/app/rest/__init__.py b/backend/app/rest/__init__.py index e7010a6b..d1d9335f 100644 --- a/backend/app/rest/__init__.py +++ b/backend/app/rest/__init__.py @@ -8,6 +8,7 @@ def init_app(app): log_records_routes, residents_routes, tags_routes, + buildings_routes, ) app.register_blueprint(user_routes.blueprint) @@ -18,3 +19,4 @@ def init_app(app): app.register_blueprint(log_records_routes.blueprint) app.register_blueprint(residents_routes.blueprint) app.register_blueprint(tags_routes.blueprint) + app.register_blueprint(buildings_routes.blueprint) diff --git a/backend/app/rest/auth_routes.py b/backend/app/rest/auth_routes.py index cf6d78d2..095618cc 100644 --- a/backend/app/rest/auth_routes.py +++ b/backend/app/rest/auth_routes.py @@ -3,6 +3,7 @@ InvalidPasswordException, TooManyLoginAttemptsException, ) +from ..utilities.exceptions.auth_exceptions import EmailAlreadyInUseException from flask import Blueprint, current_app, jsonify, request from twilio.rest import Client @@ -10,6 +11,7 @@ from ..middlewares.auth import ( require_authorization_by_user_id, require_authorization_by_email, + get_access_token, ) from ..middlewares.validate import validate_request from ..resources.create_user_dto import CreateUserDTO @@ -72,6 +74,7 @@ def login(): "last_name": auth_dto.last_name, "email": auth_dto.email, "role": auth_dto.role, + "verified": auth_service.is_authorized_by_token(auth_dto.access_token), } sign_in_logs_service.create_sign_in_log(auth_dto.id) @@ -127,6 +130,9 @@ def two_fa(): auth_dto = auth_service.generate_token( request.json["email"], request.json["password"] ) + + auth_service.send_email_verification_link(request.json["email"]) + response = jsonify( { "access_token": auth_dto.access_token, @@ -135,6 +141,7 @@ def two_fa(): "last_name": auth_dto.last_name, "email": auth_dto.email, "role": auth_dto.role, + "verified": auth_service.is_authorized_by_token(auth_dto.access_token), } ) response.set_cookie( @@ -164,13 +171,13 @@ def register(): request.json["email"], request.json["password"] ) - auth_service.send_email_verification_link(request.json["email"]) - response = {"requires_two_fa": False, "auth_user": None} if os.getenv("TWILIO_ENABLED") == "True" and auth_dto.role == "Relief Staff": response["requires_two_fa"] = True return jsonify(response), 200 + + auth_service.send_email_verification_link(request.json["email"]) response["auth_user"] = { "access_token": auth_dto.access_token, @@ -179,6 +186,7 @@ def register(): "last_name": auth_dto.last_name, "email": auth_dto.email, "role": auth_dto.role, + "verified": auth_service.is_authorized_by_token(auth_dto.access_token), } response = jsonify(response) @@ -188,6 +196,9 @@ def register(): **cookie_options, ) return response, 200 + except EmailAlreadyInUseException as e: + error_message = getattr(e, "message", None) + return jsonify({"error": (error_message if error_message else str(e))}), 409 except Exception as e: error_message = getattr(e, "message", None) return jsonify({"error": (error_message if error_message else str(e))}), 500 @@ -239,3 +250,15 @@ def reset_password(email): except Exception as e: error_message = getattr(e, "message", None) return jsonify({"error": (error_message if error_message else str(e))}), 500 + +@blueprint.route("/verify", methods=["GET"], strict_slashes=False) +def is_verified(): + """ + Checks if a user with a specified email is verified. + """ + try: + access_token = get_access_token(request) + return jsonify({"verified": auth_service.is_authorized_by_token(access_token)}), 200 + except Exception as e: + error_message = getattr(e, "message", None) + return jsonify({"error": (error_message if error_message else str(e))}), 500 diff --git a/backend/app/rest/buildings_routes.py b/backend/app/rest/buildings_routes.py new file mode 100644 index 00000000..f209cfe9 --- /dev/null +++ b/backend/app/rest/buildings_routes.py @@ -0,0 +1,20 @@ +from flask import Blueprint, current_app, jsonify, request +from ..middlewares.auth import require_authorization_by_role +from ..services.implementations.buildings_service import BuildingsService + +buildings_service = BuildingsService(current_app.logger) +blueprint = Blueprint("buildings", __name__, url_prefix="/buildings") + + +@blueprint.route("/", methods=["GET"], strict_slashes=False) +@require_authorization_by_role({"Relief Staff", "Regular Staff", "Admin"}) +def get_buildings(): + """ + Get buildings. + """ + try: + building_results = buildings_service.get_buildings() + return jsonify(building_results), 201 + except Exception as e: + error_message = getattr(e, "message", None) + return jsonify({"error": (error_message if error_message else str(e))}), 500 diff --git a/backend/app/rest/residents_routes.py b/backend/app/rest/residents_routes.py index 576c146c..6b74fa13 100644 --- a/backend/app/rest/residents_routes.py +++ b/backend/app/rest/residents_routes.py @@ -21,6 +21,16 @@ def add_resident(): 400, ) + # Check for the existence of a resident prior to adding them + fmt_resident_id = resident.get("initial") + str(resident.get("room_num")) + try: + res = residents_service.get_residents(False, 1, 10, fmt_resident_id) + if len(res["residents"]) > 0: + return jsonify({"error": "Resident already with id {fmt_resident_id} already exists".format(fmt_resident_id=fmt_resident_id)}), 409 + except Exception as e: + error_message = getattr(e, "message", None) + return jsonify({"error": (error_message if error_message else str(e))}), 500 + try: created_resident = residents_service.add_resident(resident) return jsonify(created_resident), 201 @@ -41,6 +51,17 @@ def update_resident(resident_id): jsonify({"date_left_error": "date_left cannot be less than date_joined"}), 400, ) + + # Check for the existence of a resident prior to adding them + fmt_resident_id = updated_resident.get("initial") + str(updated_resident.get("room_num")) + try: + res = residents_service.get_residents(False, 1, 10, fmt_resident_id) + if len(res["residents"]) == 1 and res["residents"][0]["id"] != resident_id: + return jsonify({"error": "Resident with id {fmt_resident_id} already exists".format(fmt_resident_id=fmt_resident_id)}), 409 + except Exception as e: + error_message = getattr(e, "message", None) + return jsonify({"error": (error_message if error_message else str(e))}), 500 + try: updated_resident = residents_service.update_resident( diff --git a/backend/app/rest/tags_routes.py b/backend/app/rest/tags_routes.py index b37e2b50..1be7211b 100644 --- a/backend/app/rest/tags_routes.py +++ b/backend/app/rest/tags_routes.py @@ -65,3 +65,18 @@ def update_tag(tag_id): except Exception as e: error_message = getattr(e, "message", None) return jsonify({"error": (error_message if error_message else str(e))}), 500 + + +@blueprint.route("/", methods=["POST"], strict_slashes=False) +@require_authorization_by_role({"Admin"}) +def create_tag(): + """ + Create a tag + """ + tag = request.json + try: + created_tag = tags_service.create_tag(tag) + return jsonify(created_tag), 201 + except Exception as e: + error_message = getattr(e, "message", None) + return jsonify({"error": (error_message if error_message else str(e))}), 500 diff --git a/backend/app/rest/user_routes.py b/backend/app/rest/user_routes.py index e222ebb8..82f236fb 100644 --- a/backend/app/rest/user_routes.py +++ b/backend/app/rest/user_routes.py @@ -12,6 +12,7 @@ from ..services.implementations.email_service import EmailService from ..services.implementations.user_service import UserService from ..utilities.csv_utils import generate_csv_from_list +from ..utilities.exceptions.auth_exceptions import UserNotInvitedException user_service = UserService(current_app.logger) @@ -91,6 +92,9 @@ def get_user_status(): email = request.args.get("email") user_status = user_service.get_user_status_by_email(email) return jsonify({"user_status": user_status, "email": email}), 201 + except UserNotInvitedException as e: + error_message = getattr(e, "message", None) + return jsonify({"error": (error_message if error_message else str(e))}), 403 except Exception as e: error_message = getattr(e, "message", None) return jsonify({"error": (error_message if error_message else str(e))}), 500 diff --git a/backend/app/services/implementations/auth_service.py b/backend/app/services/implementations/auth_service.py index 1b752008..d15d32a1 100644 --- a/backend/app/services/implementations/auth_service.py +++ b/backend/app/services/implementations/auth_service.py @@ -219,3 +219,14 @@ def is_authorized_by_email(self, access_token, requested_email): ) except: return False + + def is_authorized_by_token(self, access_token): + try: + decoded_id_token = firebase_admin.auth.verify_id_token( + access_token, check_revoked=True + ) + firebase_user = firebase_admin.auth.get_user(decoded_id_token["uid"]) + return firebase_user.email_verified + except Exception as e: + print(e) + return False diff --git a/backend/app/services/implementations/buildings_service.py b/backend/app/services/implementations/buildings_service.py new file mode 100644 index 00000000..d09db406 --- /dev/null +++ b/backend/app/services/implementations/buildings_service.py @@ -0,0 +1,31 @@ +from flask import jsonify +from ..interfaces.buildings_service import IBuildingsService +from ...models.buildings import Buildings +from ...models import db + + +class BuildingsService(IBuildingsService): + """ + Buildings implementation + """ + + def __init__(self, logger): + """ + Create an instance of BuildingsService + + :param logger: application's logger instance + :type logger: logger + """ + self.logger = logger + + def get_buildings(self): + try: + buildings_results = Buildings.query.all() + + return { + "buildings": list( + map(lambda building: building.to_dict(), buildings_results) + ) + } + except Exception as postgres_error: + raise postgres_error diff --git a/backend/app/services/implementations/log_records_service.py b/backend/app/services/implementations/log_records_service.py index 2d0c9002..4f7e23c7 100644 --- a/backend/app/services/implementations/log_records_service.py +++ b/backend/app/services/implementations/log_records_service.py @@ -1,5 +1,7 @@ from ..interfaces.log_records_service import ILogRecordsService from ...models.log_records import LogRecords +from ...models.residents import Residents +from ...models.user import User from ...models.tags import Tag from ...models import db from datetime import datetime @@ -24,12 +26,16 @@ def __init__(self, logger): def add_record(self, log_record): new_log_record = log_record.copy() - tag_names = new_log_record["tags"] + residents = new_log_record["residents"] + tags = new_log_record["tags"] + + del new_log_record["residents"] del new_log_record["tags"] try: new_log_record = LogRecords(**new_log_record) - self.construct_tags(new_log_record, tag_names) + self.construct_residents(new_log_record, residents) + self.construct_tags(new_log_record, tags) db.session.add(new_log_record) db.session.commit() @@ -37,12 +43,22 @@ def add_record(self, log_record): except Exception as postgres_error: raise postgres_error - def construct_tags(self, log_record, tag_names): - for tag_name in tag_names: - tag = Tag.query.filter_by(name=tag_name).first() + def construct_residents(self, log_record, residents): + residents = list(set(residents)) + for resident_id in residents: + resident = Residents.query.filter_by(id=resident_id).first() + + if not resident: + raise Exception(f"Resident with id {resident_id} does not exist") + log_record.residents.append(resident) + + def construct_tags(self, log_record, tags): + tags = list(set(tags)) + for tag_id in tags: + tag = Tag.query.filter_by(tag_id=tag_id).first() if not tag: - raise Exception(f"Tag with name {tag_name} does not exist") + raise Exception(f"Tag with id {tag_id} does not exist") log_record.tags.append(tag) def to_json_list(self, logs): @@ -57,7 +73,7 @@ def to_json_list(self, logs): "first_name": log[2], "last_name": log[3], }, - "resident_id": log[4], + "residents": log[4], "attn_to": { "id": log[5], "first_name": log[6], @@ -94,13 +110,15 @@ def filter_by_employee_id(self, employee_id): return sql_statement return f"\nemployee_id={employee_id}" - def filter_by_resident_id(self, resident_id): - if type(resident_id) == list: - sql_statement = f"\nresident_id={resident_id[0]}" - for i in range(1, len(resident_id)): - sql_statement = sql_statement + f"\nOR resident_id={resident_id[i]}" + def filter_by_residents(self, residents): + if type(residents) == list: + sql_statement = f"\n'{residents[0]}'=ANY (resident_ids)" + for i in range(1, len(residents)): + sql_statement = ( + sql_statement + f"\nAND '{residents[i]}'=ANY (resident_ids)" + ) return sql_statement - return f"\nresident_id={resident_id}" + return f"\n'{residents}'=ANY (resident_ids)" def filter_by_attn_to(self, attn_to): if type(attn_to) == list: @@ -130,12 +148,12 @@ def filter_by_date_range(self, date_range): return sql def filter_by_tags(self, tags): - if len(tags) >= 1: - sql_statement = f"\n'{tags[0]}'=ANY (tag_names)" + if type(tags) == list: + sql_statement = f"\n'{tags[0]}'=ANY (tag_ids)" for i in range(1, len(tags)): - sql_statement = sql_statement + f"\nAND '{tags[i]}'=ANY (tag_names)" + sql_statement = sql_statement + f"\nAND '{tags[i]}'=ANY (tag_ids)" return sql_statement - return f"\n'{tags}'=ANY (tag_names)" + return f"\n'{tags}'=ANY (tag_ids)" def filter_by_flagged(self, flagged): print(flagged) @@ -150,7 +168,7 @@ def filter_log_records(self, filters=None): options = { "building_id": self.filter_by_building_id, "employee_id": self.filter_by_employee_id, - "resident_id": self.filter_by_resident_id, + "residents": self.filter_by_residents, "attn_to": self.filter_by_attn_to, "date_range": self.filter_by_date_range, "tags": self.filter_by_tags, @@ -166,9 +184,17 @@ def filter_log_records(self, filters=None): sql = sql + "\nAND " + options[filter](filters.get(filter)) return sql + def join_resident_attributes(self): + return "\nLEFT JOIN\n \ + (SELECT logs.log_id, ARRAY_AGG(residents.id) AS resident_ids, ARRAY_AGG(CONCAT(residents.initial, residents.room_num)) AS residents FROM log_records logs\n \ + JOIN log_record_residents lrr ON logs.log_id = lrr.log_record_id\n \ + JOIN residents ON lrr.resident_id = residents.id\n \ + GROUP BY logs.log_id \n \ + ) r ON logs.log_id = r.log_id\n" + def join_tag_attributes(self): return "\nLEFT JOIN\n \ - (SELECT logs.log_id, ARRAY_AGG(tags.name) AS tag_names FROM log_records logs\n \ + (SELECT logs.log_id, ARRAY_AGG(tags.tag_id) AS tag_ids, ARRAY_AGG(tags.name) AS tag_names FROM log_records logs\n \ JOIN log_record_tag lrt ON logs.log_id = lrt.log_record_id\n \ JOIN tags ON lrt.tag_id = tags.tag_id\n \ GROUP BY logs.log_id \n \ @@ -183,7 +209,7 @@ def get_log_records( logs.employee_id,\n \ employees.first_name AS employee_first_name,\n \ employees.last_name AS employee_last_name,\n \ - CONCAT(residents.initial, residents.room_num) AS resident_id,\n \ + r.residents,\n \ logs.attn_to,\n \ attn_tos.first_name AS attn_to_first_name,\n \ attn_tos.last_name AS attn_to_last_name,\n \ @@ -196,9 +222,9 @@ def get_log_records( FROM log_records logs\n \ LEFT JOIN users attn_tos ON logs.attn_to = attn_tos.id\n \ JOIN users employees ON logs.employee_id = employees.id\n \ - JOIN residents ON logs.resident_id = residents.id\n \ JOIN buildings on logs.building_id = buildings.id" + sql += self.join_resident_attributes() sql += self.join_tag_attributes() sql += self.filter_log_records(filters) @@ -225,10 +251,10 @@ def count_log_records(self, filters=None): FROM log_records logs\n \ LEFT JOIN users attn_tos ON logs.attn_to = attn_tos.id\n \ JOIN users employees ON logs.employee_id = employees.id\n \ - JOIN residents ON logs.resident_id = residents.id\n \ JOIN buildings on logs.building_id = buildings.id" - sql += f"\n{self.join_tag_attributes()}" + sql += self.join_resident_attributes() + sql += self.join_tag_attributes() sql += self.filter_log_records(filters) num_results = db.session.execute(text(sql)) @@ -246,6 +272,7 @@ def delete_log_record(self, log_id): raise Exception( "Log record with id {log_id} not found".format(log_id=log_id) ) + log_record_to_delete.residents = [] log_record_to_delete.tags = [] db.session.delete(log_record_to_delete) db.session.commit() @@ -274,10 +301,15 @@ def update_log_record(self, log_id, updated_log_record): LogRecords.tags: None, } ) + + log_record = LogRecords.query.filter_by(log_id=log_id).first() + if log_record: + log_record.residents = [] + self.construct_residents(log_record, updated_log_record["residents"]) + updated_log_record = LogRecords.query.filter_by(log_id=log_id).update( { LogRecords.employee_id: updated_log_record["employee_id"], - LogRecords.resident_id: updated_log_record["resident_id"], LogRecords.flagged: updated_log_record["flagged"], LogRecords.building_id: updated_log_record["building_id"], LogRecords.note: updated_log_record["note"], diff --git a/backend/app/services/implementations/tags_service.py b/backend/app/services/implementations/tags_service.py index 6d81f363..4e610e78 100644 --- a/backend/app/services/implementations/tags_service.py +++ b/backend/app/services/implementations/tags_service.py @@ -1,5 +1,6 @@ from ..interfaces.tags_service import ITagsService from ...models.tags import Tag +from ...models.log_record_tags import LogRecordTag from ...models import db @@ -19,28 +20,51 @@ def __init__(self, logger): def get_tags(self): try: - tags_results = Tag.query.all() + tags_results = Tag.query.order_by(Tag.last_modified.desc()).all() tags_results = list(map(lambda tag: tag.to_dict(), tags_results)) return {"tags": tags_results} except Exception as postgres_error: raise postgres_error def delete_tag(self, tag_id): - deleted_tag = Tag.query.filter_by(tag_id=tag_id).update({"status": "Deleted"}) - if not deleted_tag: - raise Exception("Tag with id {tag_id} not found".format(tag_id=tag_id)) + tags_to_delete = Tag.query.filter_by(tag_id=tag_id).first() + if not tags_to_delete: + raise Exception( + "Log record with id {log_id} not found".format(log_id=log_id) + ) + tags_to_delete.log_records = [] + db.session.delete(tags_to_delete) db.session.commit() def update_tag(self, tag_id, updated_tag): updated_name = updated_tag["name"] - name_check = Tag.query.filter_by(name=updated_name).first() - if name_check is not None: - raise Exception("Tag name {name} already exists".format(name=updated_name)) - create_update_tag = Tag.query.filter_by(tag_id=tag_id).update( - { - **updated_tag, - } - ) - if not create_update_tag: - raise Exception("Tag with id {tag_id} not found".format(tag_id=tag_id)) - db.session.commit() + try: + create_update_tag = Tag.query.filter_by(tag_id=tag_id).update( + { + **updated_tag, + } + ) + if not create_update_tag: + raise Exception("Tag with id {tag_id} not found".format(tag_id=tag_id)) + db.session.commit() + except Exception as error: + if type(error).__name__ == "IntegrityError": + raise Exception( + "Tag name {name} already exists".format(name=updated_name) + ) + else: + raise error + + def create_tag(self, tag): + try: + new_tag = Tag(**tag) + db.session.add(new_tag) + db.session.commit() + return tag + except Exception as error: + if type(error).__name__ == "IntegrityError": + raise Exception( + "Tag name {name} already exists".format(name=tag["name"]) + ) + else: + raise error diff --git a/backend/app/services/implementations/user_service.py b/backend/app/services/implementations/user_service.py index 8a89eda3..a595e49f 100644 --- a/backend/app/services/implementations/user_service.py +++ b/backend/app/services/implementations/user_service.py @@ -4,6 +4,10 @@ from ...models.user import User from ...models import db from ...resources.user_dto import UserDTO +from ...utilities.exceptions.auth_exceptions import ( + UserNotInvitedException, + EmailAlreadyInUseException, +) class UserService(IUserService): @@ -113,10 +117,11 @@ def get_auth_id_by_user_id(self, user_id): def get_users(self, return_all, page_number, results_per_page): try: if return_all: - users = User.query.all() + users = User.query.order_by(User.last_modified.desc()).all() else: users = ( - User.query.limit(results_per_page) + User.query.order_by(User.last_modified.desc()) + .limit(results_per_page) .offset((page_number - 1) * results_per_page) .all() ) @@ -146,7 +151,7 @@ def get_user_status_by_email(self, email): user = User.query.filter_by(email=email).first() if not user: - raise Exception("user with email {email} not found".format(email)) + raise UserNotInvitedException return user.user_status except Exception as e: @@ -210,7 +215,11 @@ def activate_user(self, user, auth_id=None, signup_method="PASSWORD"): firebase_user = None try: - if self.get_user_status_by_email(user.email) == "Invited": + cur_user_status = self.get_user_status_by_email(user.email) + + if cur_user_status == "Active": + raise EmailAlreadyInUseException + if cur_user_status == "Invited": if signup_method == "PASSWORD": firebase_user = firebase_admin.auth.create_user( email=user.email, password=user.password diff --git a/backend/app/services/interfaces/buildings_service.py b/backend/app/services/interfaces/buildings_service.py new file mode 100644 index 00000000..19fa7397 --- /dev/null +++ b/backend/app/services/interfaces/buildings_service.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod + + +class IBuildingsService(ABC): + """ + BuildingsService interface with buildings methods + """ + + @abstractmethod + def get_buildings(self): + """ + Gets buildings in json format. + """ + pass diff --git a/backend/app/services/interfaces/log_records_service.py b/backend/app/services/interfaces/log_records_service.py index fb13cb44..f002743c 100644 --- a/backend/app/services/interfaces/log_records_service.py +++ b/backend/app/services/interfaces/log_records_service.py @@ -13,7 +13,7 @@ def add_record(self, log_record): :param user_id: user id of the user adding the log record :type user_id: int - :param resident_id: resident's id + :param residents: list of resident ids :param flagged: checkbox if attention is needed :type flagged: boolean :param note: note that user inputs diff --git a/backend/app/utilities/exceptions/auth_exceptions.py b/backend/app/utilities/exceptions/auth_exceptions.py new file mode 100644 index 00000000..f4069067 --- /dev/null +++ b/backend/app/utilities/exceptions/auth_exceptions.py @@ -0,0 +1,20 @@ +class UserNotInvitedException(Exception): + """ + Raised when a user that has not been invited attempts to register + """ + + def __init__(self): + self.message = "This email address has not been invited. Please try again with a different email." + super().__init__(self.message) + + +class EmailAlreadyInUseException(Exception): + """ + Raised when a user attempts to register with an email of a previously activated user + """ + + def __init__(self): + self.message = ( + "This email is already in use. Please try again with a different email." + ) + super().__init__(self.message) diff --git a/backend/migrations/versions/0c76a8fe211d_add_log_record_residents.py b/backend/migrations/versions/0c76a8fe211d_add_log_record_residents.py new file mode 100644 index 00000000..7dc48ac5 --- /dev/null +++ b/backend/migrations/versions/0c76a8fe211d_add_log_record_residents.py @@ -0,0 +1,54 @@ +"""add log_record_residents + +Revision ID: 0c76a8fe211d +Revises: 24fad25f60e3 +Create Date: 2023-10-19 00:02:43.259307 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "0c76a8fe211d" +down_revision = "0ea2257f1dc6" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "log_record_residents", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("log_record_id", sa.Integer(), nullable=False), + sa.Column("resident_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["log_record_id"], + ["log_records.log_id"], + ), + sa.ForeignKeyConstraint( + ["resident_id"], + ["residents.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("log_records", schema=None) as batch_op: + batch_op.drop_constraint("log_records_resident_id_fkey", type_="foreignkey") + batch_op.drop_column("resident_id") + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("log_records", schema=None) as batch_op: + batch_op.add_column( + sa.Column("resident_id", sa.INTEGER(), autoincrement=False, nullable=False) + ) + batch_op.create_foreign_key( + "log_records_resident_id_fkey", "residents", ["resident_id"], ["id"] + ) + + op.drop_table("log_record_residents") + # ### end Alembic commands ### diff --git a/backend/migrations/versions/0dbe140cf6f5_add_last_modified_to_tags.py b/backend/migrations/versions/0dbe140cf6f5_add_last_modified_to_tags.py new file mode 100644 index 00000000..f7eb2b0e --- /dev/null +++ b/backend/migrations/versions/0dbe140cf6f5_add_last_modified_to_tags.py @@ -0,0 +1,40 @@ +"""add last_modified to tags + +Revision ID: 0dbe140cf6f5 +Revises: 8b5132609f1f +Create Date: 2023-11-09 01:59:44.190531 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "0dbe140cf6f5" +down_revision = "698e5724baae" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("tags", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "last_modified", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + onupdate=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ) + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("tags", schema=None) as batch_op: + batch_op.drop_column("last_modified") + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/0ea2257f1dc6_tag_name_unique_constraint.py b/backend/migrations/versions/0ea2257f1dc6_tag_name_unique_constraint.py new file mode 100644 index 00000000..b205dae0 --- /dev/null +++ b/backend/migrations/versions/0ea2257f1dc6_tag_name_unique_constraint.py @@ -0,0 +1,32 @@ +"""tag name unique constraint + +Revision ID: 0ea2257f1dc6 +Revises: 8b5132609f1f +Create Date: 2023-11-08 20:53:54.334014 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "0ea2257f1dc6" +down_revision = "24fad25f60e3" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("tags", schema=None) as batch_op: + batch_op.create_unique_constraint(None, ["name"]) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("tags", schema=None) as batch_op: + batch_op.drop_constraint("tags_name_key", type_="unique") + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/117790caec65_remove_status_from_tags.py b/backend/migrations/versions/117790caec65_remove_status_from_tags.py new file mode 100644 index 00000000..aa9d4a60 --- /dev/null +++ b/backend/migrations/versions/117790caec65_remove_status_from_tags.py @@ -0,0 +1,39 @@ +"""remove status from tags + +Revision ID: 117790caec65 +Revises: 8b5132609f1f +Create Date: 2023-11-16 01:53:04.353305 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "117790caec65" +down_revision = "0dbe140cf6f5" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("tags", schema=None) as batch_op: + batch_op.drop_column("status") + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("tags", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "status", + postgresql.ENUM("Deleted", "Active", name="status"), + autoincrement=False, + nullable=False, + ) + ) + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/698e5724baae_add_last_modified_to_users.py b/backend/migrations/versions/698e5724baae_add_last_modified_to_users.py new file mode 100644 index 00000000..c20b93be --- /dev/null +++ b/backend/migrations/versions/698e5724baae_add_last_modified_to_users.py @@ -0,0 +1,40 @@ +"""add last_modified to users + +Revision ID: 698e5724baae +Revises: 8b5132609f1f +Create Date: 2023-11-08 20:26:08.758322 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "698e5724baae" +down_revision = "0c76a8fe211d" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "last_modified", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + onupdate=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ) + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.drop_column("last_modified") + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/8b5132609f1f_merging.py b/backend/migrations/versions/8b5132609f1f_merging.py deleted file mode 100644 index 10153b85..00000000 --- a/backend/migrations/versions/8b5132609f1f_merging.py +++ /dev/null @@ -1,24 +0,0 @@ -"""merging - -Revision ID: 8b5132609f1f -Revises: 24fad25f60e3, 65a56c245ad7 -Create Date: 2023-10-04 23:41:43.310280 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "8b5132609f1f" -down_revision = ("24fad25f60e3", "65a56c245ad7") -branch_labels = None -depends_on = None - - -def upgrade(): - pass - - -def downgrade(): - pass diff --git a/backend/migrations/versions/a2a0a16b6d51_add_status_and_email_to_users_table.py b/backend/migrations/versions/a2a0a16b6d51_add_status_and_email_to_users_table.py index 92447730..5708b312 100644 --- a/backend/migrations/versions/a2a0a16b6d51_add_status_and_email_to_users_table.py +++ b/backend/migrations/versions/a2a0a16b6d51_add_status_and_email_to_users_table.py @@ -12,7 +12,7 @@ # revision identifiers, used by Alembic. revision = "a2a0a16b6d51" -down_revision = "82f36cdf325f" +down_revision = "65a56c245ad7" branch_labels = None depends_on = None diff --git a/frontend/src/APIClients/AuthAPIClient.ts b/frontend/src/APIClients/AuthAPIClient.ts index bde196bc..825c3b86 100644 --- a/frontend/src/APIClients/AuthAPIClient.ts +++ b/frontend/src/APIClients/AuthAPIClient.ts @@ -4,13 +4,16 @@ import { OperationVariables, } from "@apollo/client"; import { AxiosError } from "axios"; -import getLoginErrMessage from "../helper/authErrorMessage"; +import { + getAuthErrMessage +} +from "../helper/error"; import AUTHENTICATED_USER_KEY from "../constants/AuthConstants"; import { AuthenticatedUser, AuthTokenResponse, - ErrorResponse, } from "../types/AuthTypes"; +import { AuthErrorResponse } from "../types/ErrorTypes" import baseAPIClient from "./BaseAPIClient"; import { getLocalStorageObjProperty, @@ -20,7 +23,7 @@ import { const login = async ( email: string, password: string, -): Promise => { +): Promise => { try { const { data } = await baseAPIClient.post( "/auth/login", @@ -33,7 +36,7 @@ const login = async ( if (axiosErr.response && axiosErr.response.status === 401) { return { errCode: axiosErr.response.status, - errMessage: getLoginErrMessage(axiosErr.response), + errMessage: getAuthErrMessage(axiosErr.response, 'LOGIN'), }; } return { @@ -99,7 +102,7 @@ const register = async ( lastName: string, email: string, password: string, -): Promise => { +): Promise => { try { const { data } = await baseAPIClient.post( "/auth/register", @@ -108,6 +111,13 @@ const register = async ( ); return data; } catch (error) { + const axiosErr = (error as any) as AxiosError; + if (axiosErr.response && axiosErr.response.status === 409) { + return { + errCode: axiosErr.response.status, + errMessage: getAuthErrMessage(axiosErr.response, 'SIGNUP'), + }; + } return null; } }; @@ -129,6 +139,22 @@ const resetPassword = async (email: string | undefined): Promise => { } }; +const isVerified = async (): Promise => { + const bearerToken = `Bearer ${getLocalStorageObjProperty( + AUTHENTICATED_USER_KEY, + "accessToken", + )}`; + try { + const { data } = await baseAPIClient.get( + `/auth/verify`, + { headers: { Authorization: bearerToken } }, + ); + return data.verified === true; + } catch (error) { + return false; + } +}; + // for testing only, refresh does not need to be exposed in the client const refresh = async (): Promise => { try { @@ -155,5 +181,6 @@ export default { twoFaWithGoogle, register, resetPassword, + isVerified, refresh, }; diff --git a/frontend/src/APIClients/BuildingAPIClient.ts b/frontend/src/APIClients/BuildingAPIClient.ts new file mode 100644 index 00000000..300a1c7e --- /dev/null +++ b/frontend/src/APIClients/BuildingAPIClient.ts @@ -0,0 +1,27 @@ +import axios, { AxiosError } from "axios"; +import AUTHENTICATED_USER_KEY from "../constants/AuthConstants"; +import { getLocalStorageObjProperty } from "../utils/LocalStorageUtils"; +import baseAPIClient from "./BaseAPIClient"; +import { GetBuildingsResponse } from "../types/BuildingTypes"; + +const getBuildings = async (): Promise => { + try { + const bearerToken = `Bearer ${getLocalStorageObjProperty( + AUTHENTICATED_USER_KEY, + "accessToken", + )}`; + const { data } = await baseAPIClient.get( + `/buildings`, + { + headers: { Authorization: bearerToken }, + }, + ); + return data; + } catch (error) { + return null; + } +}; + +export default { + getBuildings, +}; diff --git a/frontend/src/APIClients/CommonAPIClient.ts b/frontend/src/APIClients/CommonAPIClient.ts index 57e82598..157025fc 100644 --- a/frontend/src/APIClients/CommonAPIClient.ts +++ b/frontend/src/APIClients/CommonAPIClient.ts @@ -1,6 +1,8 @@ +import { AxiosError } from "axios"; import AUTHENTICATED_USER_KEY from "../constants/AuthConstants"; import { getLocalStorageObjProperty } from "../utils/LocalStorageUtils"; import baseAPIClient from "./BaseAPIClient"; +import { AuthErrorResponse } from "../types/ErrorTypes"; const inviteUser = async ( email: string, @@ -24,7 +26,7 @@ const inviteUser = async ( } }; -const getUserStatus = async (email: string): Promise => { +const getUserStatus = async (email: string): Promise => { try { if (email === "") { return ""; @@ -44,6 +46,13 @@ const getUserStatus = async (email: string): Promise => { } return "Not invited"; } catch (error) { + const axiosErr = (error as any) as AxiosError; + if (axiosErr.response && axiosErr.response.status === 403) { + return { + errCode: axiosErr.response.status, + errMessage: axiosErr.response.data.error, + }; + } return "Not invited"; } }; diff --git a/frontend/src/APIClients/LogRecordAPIClient.ts b/frontend/src/APIClients/LogRecordAPIClient.ts index 4dc23873..838eae7c 100644 --- a/frontend/src/APIClients/LogRecordAPIClient.ts +++ b/frontend/src/APIClients/LogRecordAPIClient.ts @@ -16,7 +16,7 @@ const countLogRecords = async ({ employeeId = [], attnTo = [], dateRange = [], - residentId = [], + residents = [], tags = [], flagged = false, }: CountLogRecordFilters): Promise => { @@ -32,7 +32,7 @@ const countLogRecords = async ({ employeeId, attnTo, dateRange, - residentId, + residents, tags, flagged, }, @@ -51,7 +51,7 @@ const filterLogRecords = async ({ employeeId = [], attnTo = [], dateRange = [], - residentId = [], + residents = [], tags = [], flagged = false, returnAll = false, @@ -70,7 +70,7 @@ const filterLogRecords = async ({ employeeId, attnTo, dateRange, - residentId, + residents, tags, flagged, }, @@ -89,7 +89,7 @@ const filterLogRecords = async ({ const createLog = async ({ employeeId, - residentId, + residents, datetime, flagged, note, @@ -106,7 +106,7 @@ const createLog = async ({ "/log_records/", { employeeId, - residentId, + residents, datetime, flagged, note, @@ -140,7 +140,7 @@ const deleteLogRecord = async (logId: number): Promise => { const editLogRecord = async ({ logId, employeeId, - residentId, + residents, datetime, flagged, note, @@ -157,7 +157,7 @@ const editLogRecord = async ({ `/log_records/${logId}`, { employeeId, - residentId, + residents, datetime, flagged, note, diff --git a/frontend/src/APIClients/ResidentAPIClient.ts b/frontend/src/APIClients/ResidentAPIClient.ts index df95a686..64fb4cdb 100644 --- a/frontend/src/APIClients/ResidentAPIClient.ts +++ b/frontend/src/APIClients/ResidentAPIClient.ts @@ -7,6 +7,7 @@ import { CreateResidentParams, EditResidentParams, } from "../types/ResidentTypes"; +import { ResidentErrorResponse } from "../types/ErrorTypes" import { getLocalStorageObjProperty } from "../utils/LocalStorageUtils"; import baseAPIClient from "./BaseAPIClient"; @@ -60,7 +61,7 @@ const createResident = async ({ roomNum, dateJoined, buildingId, -}: CreateResidentParams): Promise => { +}: CreateResidentParams): Promise => { try { const bearerToken = `Bearer ${getLocalStorageObjProperty( AUTHENTICATED_USER_KEY, @@ -73,6 +74,13 @@ const createResident = async ({ ); return true; } catch (error) { + const axiosErr = (error as any) as AxiosError; + + if (axiosErr.response && axiosErr.response.status === 409) { + return { + errMessage: "Resident with the specified user ID already exists." + }; + } return false; } }; @@ -103,7 +111,7 @@ const editResident = async ({ dateJoined, buildingId, dateLeft, -}: EditResidentParams): Promise => { +}: EditResidentParams): Promise => { try { const bearerToken = `Bearer ${getLocalStorageObjProperty( AUTHENTICATED_USER_KEY, @@ -116,6 +124,13 @@ const editResident = async ({ ); return true; } catch (error) { + const axiosErr = (error as any) as AxiosError; + + if (axiosErr.response && axiosErr.response.status === 409) { + return { + errMessage: "Resident with the specified user ID already exists." + }; + } return false; } }; diff --git a/frontend/src/APIClients/TagAPIClient.ts b/frontend/src/APIClients/TagAPIClient.ts new file mode 100644 index 00000000..5c53885f --- /dev/null +++ b/frontend/src/APIClients/TagAPIClient.ts @@ -0,0 +1,24 @@ +import { AxiosError } from "axios"; +import AUTHENTICATED_USER_KEY from "../constants/AuthConstants"; +import { getLocalStorageObjProperty } from "../utils/LocalStorageUtils"; +import baseAPIClient from "./BaseAPIClient"; +import { GetTagsResponse } from "../types/TagTypes"; + +const getTags = async (): Promise => { + try { + const bearerToken = `Bearer ${getLocalStorageObjProperty( + AUTHENTICATED_USER_KEY, + "accessToken", + )}`; + const { data } = await baseAPIClient.get(`/tags`, { + headers: { Authorization: bearerToken }, + }); + return data; + } catch (error) { + return null; + } +}; + +export default { + getTags, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f7b63f07..32ed7102 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,11 +7,9 @@ import { ChakraProvider } from "@chakra-ui/react"; import LoginPage from "./components/pages/LoginPage"; import SignupPage from "./components/pages/SignupPage"; import PrivateRoute from "./components/auth/PrivateRoute"; -import CreatePage from "./components/pages/CreatePage"; -import DisplayPage from "./components/pages/DisplayPage"; +import Verification from "./components/auth/Verification"; import HomePage from "./components/pages/HomePage/HomePage"; import NotFound from "./components/pages/NotFound"; -import UpdatePage from "./components/pages/UpdatePage"; import * as Routes from "./constants/Routes"; import AUTHENTICATED_USER_KEY from "./constants/AuthConstants"; import AuthContext from "./contexts/AuthContext"; @@ -21,14 +19,13 @@ import SampleContext, { } from "./contexts/SampleContext"; import sampleContextReducer from "./reducers/SampleContextReducer"; import SampleContextDispatcherContext from "./contexts/SampleContextDispatcherContext"; -import EditTeamInfoPage from "./components/pages/EditTeamPage"; -import HooksDemo from "./components/pages/HooksDemo"; import ResidentDirectory from "./components/pages/ResidentDirectory/ResidentDirectory"; import { AuthenticatedUser } from "./types/AuthTypes"; import customTheme from "./theme"; import EmployeeDirectoryPage from "./components/pages/AdminControls/EmployeeDirectory"; +import SignInLogsPage from "./components/pages/AdminControls/SignInLogs"; const App = (): React.ReactElement => { const currentUser: AuthenticatedUser | null = getLocalStorageObj( @@ -61,6 +58,11 @@ const App = (): React.ReactElement => { + { path={Routes.EMPLOYEE_DIRECTORY_PAGE} component={EmployeeDirectoryPage} /> + diff --git a/frontend/src/components/auth/PrivateRoute.tsx b/frontend/src/components/auth/PrivateRoute.tsx index f0204321..c48b8b9d 100644 --- a/frontend/src/components/auth/PrivateRoute.tsx +++ b/frontend/src/components/auth/PrivateRoute.tsx @@ -1,8 +1,7 @@ -import React, { useContext } from "react"; -import { Route, Redirect } from "react-router-dom"; - +import React, { useContext, useState, useEffect } from "react"; +import { Route, Redirect, useLocation } from "react-router-dom"; import AuthContext from "../../contexts/AuthContext"; -import { LOGIN_PAGE } from "../../constants/Routes"; +import { LOGIN_PAGE, VERIFICATION_PAGE } from "../../constants/Routes"; type PrivateRouteProps = { component: React.FC; @@ -16,12 +15,26 @@ const PrivateRoute: React.FC = ({ path, }: PrivateRouteProps) => { const { authenticatedUser } = useContext(AuthContext); + const location = useLocation(); + const currentPath = location.pathname; + + if (!authenticatedUser) { + return ( + + ) + } + + if (authenticatedUser.verified === false) { + if (!currentPath.endsWith("/verification")) { + return ( + + ) + } + } - return authenticatedUser ? ( + return ( - ) : ( - - ); + ) }; export default PrivateRoute; diff --git a/frontend/src/components/auth/Signup.tsx b/frontend/src/components/auth/Signup.tsx deleted file mode 100644 index 8e4ac0d0..00000000 --- a/frontend/src/components/auth/Signup.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import React, { useContext } from "react"; -import { Redirect } from "react-router-dom"; - -import authAPIClient from "../../APIClients/AuthAPIClient"; -import { HOME_PAGE } from "../../constants/Routes"; -import AuthContext from "../../contexts/AuthContext"; -import commonApiClient from "../../APIClients/CommonAPIClient"; -import AUTHENTICATED_USER_KEY from "../../constants/AuthConstants"; - -type SignupProps = { - email: string; - setEmail: (email: string) => void; - firstName: string; - setFirstName: (firstName: string) => void; - lastName: string; - setLastName: (lastName: string) => void; - password: string; - setPassword: (password: string) => void; - toggle: boolean; - setToggle: (toggle: boolean) => void; -}; - -const Signup = ({ - email, - setEmail, - firstName, - setFirstName, - lastName, - setLastName, - password, - setPassword, - toggle, - setToggle, -}: SignupProps): React.ReactElement => { - const { authenticatedUser, setAuthenticatedUser } = useContext(AuthContext); - const onSignupClick = async () => { - const isInvited = await commonApiClient.isUserInvited(email); - if (isInvited) { - const registerResponse = await authAPIClient.register( - firstName, - lastName, - email, - password, - ); - if (registerResponse) { - const { requiresTwoFa, authUser } = registerResponse; - if (requiresTwoFa) { - setToggle(!toggle); - } else { - localStorage.setItem( - AUTHENTICATED_USER_KEY, - JSON.stringify(authUser), - ); - setAuthenticatedUser(authUser); - } - } - } else { - // TODO: make this alert better and also differentiate between - // when a user is not invited and when a user's account already exists - // eslint-disable-next-line no-alert - window.alert("user not invited"); - } - }; - - if (authenticatedUser) { - return ; - } - - if (toggle) { - return ( -
-

Signup

-
-
- setFirstName(event.target.value)} - placeholder="first name" - /> -
-
- setLastName(event.target.value)} - placeholder="last name" - /> -
-
- setEmail(event.target.value)} - placeholder="username@domain.com" - /> -
-
- setPassword(event.target.value)} - placeholder="password" - /> -
-
- -
-
-
- ); - } - return <>; -}; - -export default Signup; diff --git a/frontend/src/components/auth/Verification.tsx b/frontend/src/components/auth/Verification.tsx new file mode 100644 index 00000000..4a8539d8 --- /dev/null +++ b/frontend/src/components/auth/Verification.tsx @@ -0,0 +1,61 @@ +import React, { useState, useContext } from "react"; +import { useHistory } from "react-router-dom"; +import { + Box, + Button, + Flex, + Text, + VStack +} from "@chakra-ui/react"; +import authAPIClient from "../../APIClients/AuthAPIClient"; +import CreateToast from "../common/Toasts"; +import AuthContext from "../../contexts/AuthContext"; +import { HOME_PAGE } from "../../constants/Routes"; + +const Verification = (): React.ReactElement => { + const newToast = CreateToast(); + const history = useHistory(); + const { authenticatedUser, setAuthenticatedUser } = useContext(AuthContext); + + const handleVerification = async () => { + if (authenticatedUser) { + const authUser = authenticatedUser; + authUser.verified = await authAPIClient.isVerified(); + setAuthenticatedUser(authUser); + + if (authenticatedUser.verified === false) { + newToast("Not Verified", "Please check your email for the verification email.", "error"); + } else { + history.push(HOME_PAGE); + } + } + }; + + return ( + <> + + + + Verification + In order to start using your SHOW account, you need to confirm your email address. + + + + + + ); +}; + +export default Verification; diff --git a/frontend/src/components/common/NavigationBar.tsx b/frontend/src/components/common/NavigationBar.tsx index 5edb611f..14d70d02 100644 --- a/frontend/src/components/common/NavigationBar.tsx +++ b/frontend/src/components/common/NavigationBar.tsx @@ -15,6 +15,7 @@ import { HOME_PAGE, RESIDENT_DIRECTORY_PAGE, EMPLOYEE_DIRECTORY_PAGE, + SIGN_IN_LOGS_PAGE, } from "../../constants/Routes"; import authAPIClient from "../../APIClients/AuthAPIClient"; @@ -32,6 +33,9 @@ const NavigationBar = (): React.ReactElement => { const navigateToEmployeeDirectory = () => history.push(EMPLOYEE_DIRECTORY_PAGE); + + const navigateToSignInLogs = () => + history.push(SIGN_IN_LOGS_PAGE); const handleLogout = async () => { const success = await authAPIClient.logout(authenticatedUser?.id); @@ -102,6 +106,9 @@ const NavigationBar = (): React.ReactElement => { Employee Directory + + Sign In Logs + )} diff --git a/frontend/src/components/common/Pagination.tsx b/frontend/src/components/common/Pagination.tsx index c519a1eb..5f6fa1d9 100644 --- a/frontend/src/components/common/Pagination.tsx +++ b/frontend/src/components/common/Pagination.tsx @@ -40,7 +40,7 @@ const Pagination = ({ setResultsPerPage, getRecords, }: Props): React.ReactElement => { - const numPages = Math.ceil(numRecords / resultsPerPage); + const numPages = Math.ceil(Math.max(1, numRecords) / resultsPerPage); const handleNumberInputChange = ( newUserPageNumString: string, diff --git a/frontend/src/components/forms/CreateEmployee.tsx b/frontend/src/components/forms/CreateEmployee.tsx index b5215b96..3cd288ed 100644 --- a/frontend/src/components/forms/CreateEmployee.tsx +++ b/frontend/src/components/forms/CreateEmployee.tsx @@ -29,7 +29,7 @@ type Props = { getRecords: (pageNumber: number) => Promise; setUserPageNum: React.Dispatch>; countUsers: () => Promise; -} +}; const RoleOptions = ["Relief Staff", "Admin", "Regular Staff"]; const CreateEmployee = ({ diff --git a/frontend/src/components/forms/CreateLog.tsx b/frontend/src/components/forms/CreateLog.tsx index 06317325..d7f945e4 100644 --- a/frontend/src/components/forms/CreateLog.tsx +++ b/frontend/src/components/forms/CreateLog.tsx @@ -31,13 +31,17 @@ import { Col, Row } from "react-bootstrap"; import { AuthenticatedUser } from "../../types/AuthTypes"; import UserAPIClient from "../../APIClients/UserAPIClient"; import ResidentAPIClient from "../../APIClients/ResidentAPIClient"; +import TagAPIClient from "../../APIClients/TagAPIClient"; import { getLocalStorageObj } from "../../utils/LocalStorageUtils"; import AUTHENTICATED_USER_KEY from "../../constants/AuthConstants"; import LogRecordAPIClient from "../../APIClients/LogRecordAPIClient"; +import BuildingAPIClient from "../../APIClients/BuildingAPIClient"; +import { BuildingLabel } from "../../types/BuildingTypes"; import selectStyle from "../../theme/forms/selectStyles"; import { singleDatePickerStyle } from "../../theme/forms/datePickerStyles"; import { UserLabel } from "../../types/UserTypes"; -import { ResidentLabel } from "../../types/ResidentTypes"; +import { Resident, ResidentLabel } from "../../types/ResidentTypes"; +import { TagLabel } from "../../types/TagTypes"; import combineDateTime from "../../helper/combineDateTime"; type Props = { @@ -56,11 +60,6 @@ type AlertDataOptions = { }; // Ideally we should be storing this information in the database -const BUILDINGS = [ - { label: "144", value: 1 }, - { label: "362", value: 2 }, - { label: "402", value: 3 }, -]; const ALERT_DATA: AlertDataOptions = { DEFAULT: { @@ -120,14 +119,16 @@ const CreateLog = ({ getRecords, countRecords, setUserPageNum }: Props) => { }), ); const [buildingId, setBuildingId] = useState(-1); - const [resident, setResident] = useState(-1); - const [tags, setTags] = useState([]); + const [residents, setResidents] = useState([]); + const [tags, setTags] = useState([]); const [attnTo, setAttnTo] = useState(-1); const [notes, setNotes] = useState(""); const [flagged, setFlagged] = useState(false); const [employeeOptions, setEmployeeOptions] = useState([]); - const [residentOptions, setResidentOptions] = useState([]); + const [residentOptions, setResidentOptions] = useState([]); + const [buildingOptions, setBuildingOptions] = useState([]); + const [tagOptions, setTagOptions] = useState([]); const [isCreateOpen, setCreateOpen] = React.useState(false); @@ -172,21 +173,28 @@ const CreateLog = ({ getRecords, countRecords, setUserPageNum }: Props) => { setBuildingError(selectedOption === null); }; - const handleResidentChange = ( - selectedOption: SingleValue<{ label: string; value: number }>, + const handleResidentsChange = ( + selectedResidents: MultiValue, ) => { - if (selectedOption !== null) { - setResident(selectedOption.value); + const mutableSelectedResidents: ResidentLabel[] = Array.from( + selectedResidents, + ); + if (mutableSelectedResidents !== null) { + setResidents(mutableSelectedResidents.map((residentLabel) => residentLabel.value)); } - - setResidentError(selectedOption === null); + setResidentError(mutableSelectedResidents.length === 0); + }; const handleTagsChange = ( - selectedTags: MultiValue<{ label: string; value: string }>, + selectedTags: MultiValue, ) => { - const newTagsList = selectedTags.map((tag) => tag.value); - setTags(newTagsList); + const mutableSelectedTags: TagLabel[] = Array.from( + selectedTags, + ); + if (mutableSelectedTags !== null) { + setTags(mutableSelectedTags.map((tagLabel) => tagLabel.value)); + } }; const handleAttnToChange = ( @@ -205,8 +213,17 @@ const CreateLog = ({ getRecords, countRecords, setUserPageNum }: Props) => { setNotesError(inputValue === ""); }; - // fetch resident + employee data for log creation + // fetch resident + employee + tag data for log creation const getLogEntryOptions = async () => { + const buildingsData = await BuildingAPIClient.getBuildings(); + + if (buildingsData && buildingsData.buildings.length !== 0) { + const buildingLabels: BuildingLabel[] = buildingsData.buildings.map( + (building) => ({ label: building.name!, value: building.id! }), + ); + setBuildingOptions(buildingLabels); + } + const residentsData = await ResidentAPIClient.getResidents({ returnAll: true, }); @@ -229,6 +246,16 @@ const CreateLog = ({ getRecords, countRecords, setUserPageNum }: Props) => { })); setEmployeeOptions(userLabels); } + + const tagsData = await TagAPIClient.getTags(); + if (tagsData && tagsData.tags.length !== 0) { + const tagLabels: TagLabel[] = tagsData.tags + .map((tag) => ({ + label: tag.name, + value: tag.tagId, + })); + setTagOptions(tagLabels); + } }; const handleCreateOpen = () => { @@ -245,7 +272,7 @@ const CreateLog = ({ getRecords, countRecords, setUserPageNum }: Props) => { }), ); setBuildingId(-1); - setResident(-1); + setResidents([]); setTags([]); setAttnTo(-1); setNotes(""); @@ -272,7 +299,7 @@ const CreateLog = ({ getRecords, countRecords, setUserPageNum }: Props) => { setDateError(date === null); setTimeError(time === ""); setBuildingError(buildingId === -1); - setResidentError(resident === -1); + setResidentError(residents.length === 0); setNotesError(notes === ""); // If any required fields are empty, prevent form submission @@ -281,7 +308,7 @@ const CreateLog = ({ getRecords, countRecords, setUserPageNum }: Props) => { date === null || time === "" || buildingId === -1 || - resident === -1 || + residents.length === 0 || notes === "" ) { return; @@ -294,7 +321,7 @@ const CreateLog = ({ getRecords, countRecords, setUserPageNum }: Props) => { const attentionTo = attnTo === -1 ? undefined : attnTo; const res = await LogRecordAPIClient.createLog({ employeeId: employee.value, - residentId: resident, + residents, datetime: combineDateTime(date, time), flagged, note: notes, @@ -387,7 +414,7 @@ const CreateLog = ({ getRecords, countRecords, setUserPageNum }: Props) => { Building Resident is required. @@ -414,9 +443,7 @@ const CreateLog = ({ getRecords, countRecords, setUserPageNum }: Props) => { Tags void; employeeOptions: UserLabel[]; - residentOptions: UserLabel[]; + residentOptions: ResidentLabel[]; + tagOptions: TagLabel[]; getRecords: (pageNumber: number) => Promise; countRecords: () => Promise; setUserPageNum: React.Dispatch>; + buildingOptions: BuildingLabel[]; }; type AlertData = { @@ -58,13 +63,6 @@ type AlertDataOptions = { [key: string]: AlertData; }; -// Ideally we should be storing this information in the database -const BUILDINGS = [ - { label: "144", value: 1 }, - { label: "362", value: 2 }, - { label: "402", value: 3 }, -]; - const ALERT_DATA: AlertDataOptions = { DEFAULT: { status: "info", @@ -106,9 +104,11 @@ const EditLog = ({ toggleClose, employeeOptions, residentOptions, + tagOptions, getRecords, countRecords, setUserPageNum, + buildingOptions, }: Props) => { // currently, the select for employees is locked and should default to current user. Need to check if admins/regular staff are allowed to change this const [employee, setEmployee] = useState(getCurUserSelectOption()); @@ -121,8 +121,8 @@ const EditLog = ({ }), ); const [buildingId, setBuildingId] = useState(-1); - const [resident, setResident] = useState(-1); - const [tags, setTags] = useState([]); + const [residents, setResidents] = useState([]); + const [tags, setTags] = useState([]); const [attnTo, setAttnTo] = useState(-1); const [notes, setNotes] = useState(""); const [flagged, setFlagged] = useState(false); @@ -168,20 +168,28 @@ const EditLog = ({ setBuildingError(selectedOption === null); }; - const handleResidentChange = ( - selectedOption: SingleValue<{ label: string; value: number }>, + const handleResidentsChange = ( + selectedResidents: MultiValue, ) => { - if (selectedOption !== null) { - setResident(selectedOption.value); - setResidentError(false); + const mutableSelectedResidents: ResidentLabel[] = Array.from( + selectedResidents, + ); + if (mutableSelectedResidents !== null) { + setResidents(mutableSelectedResidents.map((residentLabel) => residentLabel.value)); } + setResidentError(mutableSelectedResidents.length === 0); + }; const handleTagsChange = ( - selectedTags: MultiValue<{ label: string; value: string }>, + selectedTags: MultiValue, ) => { - const newTagsList = selectedTags.map((tag) => tag.value); - setTags(newTagsList); + const mutableSelectedTags: TagLabel[] = Array.from( + selectedTags, + ); + if (mutableSelectedTags !== null) { + setTags(mutableSelectedTags.map((tagLabel) => tagLabel.value)); + } }; const handleAttnToChange = ( @@ -212,11 +220,14 @@ const EditLog = ({ }), ); setBuildingId(logRecord.building.id); - const residentId = residentOptions.find( - (item) => item.label === logRecord.residentId, - )?.value; - setResident(residentId !== undefined ? residentId : -1); - setTags(logRecord.tags); + const residentIds = residentOptions.filter( + (item) => logRecord.residents && logRecord.residents.includes(item.label), + ).map((item) => item.value); + setResidents(residentIds); + const tagIds = tagOptions.filter( + (item) => logRecord.tags.includes(item.label), + ).map((item) => item.value); + setTags(tagIds); setAttnTo(logRecord.attnTo ? logRecord.attnTo.id : -1); setNotes(logRecord.note); setFlagged(logRecord.flagged); @@ -236,7 +247,7 @@ const EditLog = ({ setDateError(date === null); setTimeError(time === ""); setBuildingError(buildingId === -1); - setResidentError(resident === -1); + setResidentError(residents.length === 0); setNotesError(notes === ""); // If any required fields are empty, prevent form submission @@ -245,7 +256,7 @@ const EditLog = ({ date === null || time === "" || buildingId === -1 || - resident === -1 || + residents.length === 0 || notes === "" ) { return; @@ -254,7 +265,7 @@ const EditLog = ({ const res = await LogRecordAPIClient.editLogRecord({ logId: logRecord.logId, employeeId: employee.value, - residentId: resident, + residents, datetime: combineDateTime(date, time), flagged, note: notes, @@ -343,11 +354,11 @@ const EditLog = ({ Building item.label === logRecord.residentId, + isMulti + closeMenuOnSelect={false} + placeholder="Select Residents" + onChange={handleResidentsChange} + defaultValue={residentOptions.filter( + (item) => logRecord.residents && logRecord.residents.includes(item.label), )} + styles={selectStyle} /> Resident is required. @@ -376,14 +389,15 @@ const EditLog = ({ Tags item.value === buildingId, )} onChange={handleBuildingChange} diff --git a/frontend/src/components/forms/Login.tsx b/frontend/src/components/forms/Login.tsx index cfd37f26..4cd6da35 100644 --- a/frontend/src/components/forms/Login.tsx +++ b/frontend/src/components/forms/Login.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useState } from "react"; +import React, { useContext, useState, useEffect } from "react"; import { Box, Button, @@ -11,10 +11,12 @@ import { import { Redirect, useHistory } from "react-router-dom"; import authAPIClient from "../../APIClients/AuthAPIClient"; import AUTHENTICATED_USER_KEY from "../../constants/AuthConstants"; -import { HOME_PAGE, SIGNUP_PAGE } from "../../constants/Routes"; +import { HOME_PAGE, SIGNUP_PAGE, VERIFICATION_PAGE } from "../../constants/Routes"; import AuthContext from "../../contexts/AuthContext"; -import { ErrorResponse, AuthTokenResponse } from "../../types/AuthTypes"; +import { AuthTokenResponse } from "../../types/AuthTypes"; +import { AuthErrorResponse } from "../../types/ErrorTypes" import commonApiClient from "../../APIClients/CommonAPIClient"; +import { isAuthErrorResponse } from "../../helper/error"; type CredentialsProps = { email: string; @@ -26,12 +28,6 @@ type CredentialsProps = { setToggle: (toggle: boolean) => void; }; -const isLoginErrorResponse = ( - res: AuthTokenResponse | ErrorResponse, -): res is ErrorResponse => { - return res !== null && "errCode" in res; -}; - const Login = ({ email, setEmail, @@ -41,7 +37,7 @@ const Login = ({ toggle, setToggle, }: CredentialsProps): React.ReactElement => { - const { authenticatedUser, setAuthenticatedUser } = useContext(AuthContext); + const { setAuthenticatedUser } = useContext(AuthContext); const history = useHistory(); const [emailError, setEmailError] = useState(false); const [passwordError, setPasswordError] = useState(false); @@ -77,11 +73,11 @@ const Login = ({ const onLogInClick = async () => { setLoginClicked(true); const isInvited = await commonApiClient.isUserInvited(email); - if (isInvited) { + if (isInvited !== "Not Invited") { const loginResponse: | AuthTokenResponse - | ErrorResponse = await authAPIClient.login(email, password); - if (isLoginErrorResponse(loginResponse)) { + | AuthErrorResponse = await authAPIClient.login(email, password); + if (isAuthErrorResponse(loginResponse)) { setPasswordError(true); setPasswordErrStr(loginResponse.errMessage); } else if (loginResponse) { @@ -103,23 +99,17 @@ const Login = ({ history.push(SIGNUP_PAGE); }; - if (authenticatedUser) { - return ; - } - if (toggle) { - // Lock scroll - document.body.style.overflow = "hidden"; return ( - + - + Log In @@ -148,24 +138,26 @@ const Login = ({ _hover={ email && password ? { - background: "teal.500", - transition: - "transition: background-color 0.5s ease !important", - } + background: "teal.500", + transition: + "transition: background-color 0.5s ease !important", + } : {} } onClick={onLogInClick} > Log In - - - Not a member yet? - - - Sign Up Now - - + + + + Not a member yet? + + + Sign Up Now + + + diff --git a/frontend/src/components/forms/Signup.tsx b/frontend/src/components/forms/Signup.tsx index bc7cfd4d..c00793e4 100644 --- a/frontend/src/components/forms/Signup.tsx +++ b/frontend/src/components/forms/Signup.tsx @@ -1,11 +1,20 @@ -import React, { useContext } from "react"; +import React, { useState, useContext } from "react"; import { Redirect, useHistory } from "react-router-dom"; -import { Box, Button, Flex, Input, Text } from "@chakra-ui/react"; +import { + Box, + Button, + Flex, + FormControl, + FormErrorMessage, + Input, + Text +} from "@chakra-ui/react"; import authAPIClient from "../../APIClients/AuthAPIClient"; import { HOME_PAGE, LOGIN_PAGE } from "../../constants/Routes"; import AuthContext from "../../contexts/AuthContext"; import commonApiClient from "../../APIClients/CommonAPIClient"; import AUTHENTICATED_USER_KEY from "../../constants/AuthConstants"; +import { isAuthErrorResponse } from "../../helper/error"; type SignupProps = { email: string; @@ -20,6 +29,8 @@ type SignupProps = { setToggle: (toggle: boolean) => void; }; +const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + const Signup = ({ email, setEmail, @@ -32,38 +43,98 @@ const Signup = ({ toggle, setToggle, }: SignupProps): React.ReactElement => { + const [signupClicked, setSignupClicked] = useState(false); + const [emailError, setEmailError] = useState(false); + const [emailErrorStr, setEmailErrorStr] = useState(""); + const [passwordError, setPasswordError] = useState(false); + const [passwordErrorStr, setPasswordErrorStr] = useState(""); + const { authenticatedUser, setAuthenticatedUser } = useContext(AuthContext); const history = useHistory(); + const handleEmailChange = (e: React.ChangeEvent) => { + const inputValue = e.target.value as string; + if (signupClicked) { + if (emailRegex.test(inputValue)) { + setEmailErrorStr("") + setEmailError(false) + } else { + setEmailErrorStr("Please enter a valid email.") + setEmailError(true) + } + } + setEmail(inputValue) + }; + + const handlePasswordChange = (e: React.ChangeEvent) => { + const inputValue = e.target.value as string; + setPassword(inputValue) + + if (signupClicked) { + if (inputValue.length >= 6) { + setPasswordErrorStr("") + setPasswordError(false) + } + else { + setPasswordErrorStr("Password must be 6 characters long.") + setPasswordError(true) + } + } + } + const onSignupClick = async () => { + setSignupClicked(true) + + if (!emailRegex.test(email)) { + setEmailErrorStr("Please enter a valid email.") + setEmailError(true) + return + } + + if (password.length < 6) { + setPasswordErrorStr("Password must be 6 characters long.") + setPasswordError(true) + return + } + const isInvited = await commonApiClient.isUserInvited(email); - if (isInvited) { - const registerResponse = await authAPIClient.register( - firstName, - lastName, - email, - password, - ); - if (registerResponse) { - const { requiresTwoFa, authUser } = registerResponse; - if (requiresTwoFa) { - setToggle(!toggle); - } else { - localStorage.setItem( - AUTHENTICATED_USER_KEY, - JSON.stringify(authUser), - ); - setAuthenticatedUser(authUser); + if (isInvited !== "Not Invited") { + if (isAuthErrorResponse(isInvited)) { + setEmailErrorStr(isInvited.errMessage) + setEmailError(true) + } + else { + const registerResponse = await authAPIClient.register( + firstName, + lastName, + email, + password, + ); + if (registerResponse) { + if (isAuthErrorResponse(registerResponse)) { + setEmailErrorStr(registerResponse.errMessage) + setEmailError(true) + } + else { + const { requiresTwoFa, authUser } = registerResponse; + if (requiresTwoFa) { + setToggle(!toggle); + } else { + localStorage.setItem( + AUTHENTICATED_USER_KEY, + JSON.stringify(authUser), + ); + setAuthenticatedUser(authUser); + } + } } } - } else { - // TODO: make this alert better and also differentiate between - // when a user is not invited and when a user's account already exists - // eslint-disable-next-line no-alert - window.alert("user not invited"); } }; + const isCreateAccountBtnDisabled = () => + emailError || passwordError || email === '' || password === '' || firstName === '' || lastName === '' + const onLogInClick = () => { history.push(LOGIN_PAGE); }; @@ -72,107 +143,89 @@ const Signup = ({ return ; } - if (toggle) { + if (toggle) { return ( - - Sign Up - - - - - setFirstName(event.target.value)} - /> + + + Sign Up + + + + setFirstName(event.target.value)} + /> + + + setLastName(event.target.value)} + /> + + + + + {emailErrorStr} + + + + + + {passwordErrorStr} + + + + + + + + + Already have an account? + + + Log In Now + + - - setLastName(event.target.value)} - /> - - - setEmail(event.target.value)} - /> - - - setPassword(event.target.value)} - /> - - - - - - - - Already have an account? - - - Log In Now - diff --git a/frontend/src/components/pages/AdminControls/EmployeeDirectory.tsx b/frontend/src/components/pages/AdminControls/EmployeeDirectory.tsx index 96a824a9..92c622a5 100644 --- a/frontend/src/components/pages/AdminControls/EmployeeDirectory.tsx +++ b/frontend/src/components/pages/AdminControls/EmployeeDirectory.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; -import { Box, Flex } from "@chakra-ui/react"; +import { Box, Flex, Spinner } from "@chakra-ui/react"; import Pagination from "../../common/Pagination"; import NavigationBar from "../../common/NavigationBar"; @@ -18,10 +18,13 @@ const EmployeeDirectoryPage = (): React.ReactElement => { const [pageNum, setPageNum] = useState(1); const [userPageNum, setUserPageNum] = useState(pageNum); + const [tableLoaded, setTableLoaded] = useState(false) + // Table reference const tableRef = useRef(null); const getUsers = async (pageNumber: number) => { + setTableLoaded(false) const data = await UserAPIClient.getUsers({ pageNumber, resultsPerPage }); // Reset table scroll @@ -35,6 +38,8 @@ const EmployeeDirectoryPage = (): React.ReactElement => { } else { setPageNum(pageNumber); } + + setTableLoaded(true) }; const countUsers = async () => { @@ -71,23 +76,34 @@ const EmployeeDirectoryPage = (): React.ReactElement => { /> - - + {!tableLoaded ? ( + + ) : ( + + + + + )} ); diff --git a/frontend/src/components/pages/AdminControls/EmployeeDirectoryTable.tsx b/frontend/src/components/pages/AdminControls/EmployeeDirectoryTable.tsx index 986ed45f..fb8ae2fd 100644 --- a/frontend/src/components/pages/AdminControls/EmployeeDirectoryTable.tsx +++ b/frontend/src/components/pages/AdminControls/EmployeeDirectoryTable.tsx @@ -124,7 +124,7 @@ const EmployeeDirectoryTable = ({ "Employee has been successfully activated.", "success", ); - getRecords(userPageNum) + getRecords(userPageNum); setIsActivateModalOpen(false); } else { newToast( @@ -146,7 +146,7 @@ const EmployeeDirectoryTable = ({ "Employee has been successfully deactivated.", "success", ); - getRecords(userPageNum) + getRecords(userPageNum); setIsDeactivateModalOpen(false); } else { newToast( @@ -165,11 +165,7 @@ const EmployeeDirectoryTable = ({ "Employee has been successfully deleted.", "success", ); - const newUserPageNum = ( - users.length === 1 - ? userPageNum - 1 - : userPageNum - ); + const newUserPageNum = users.length === 1 ? userPageNum - 1 : userPageNum; countUsers(); getRecords(newUserPageNum); setUserPageNum(newUserPageNum); diff --git a/frontend/src/components/pages/AdminControls/SignInLogs.tsx b/frontend/src/components/pages/AdminControls/SignInLogs.tsx new file mode 100644 index 00000000..70d876da --- /dev/null +++ b/frontend/src/components/pages/AdminControls/SignInLogs.tsx @@ -0,0 +1,79 @@ +import React, { useRef, useState } from "react"; +import { Box, Flex } from "@chakra-ui/react"; +import Pagination from "../../common/Pagination"; +import NavigationBar from "../../common/NavigationBar"; +import { User } from "../../../types/UserTypes"; +import SignInLogsTable from "./SignInLogsTable"; +import UserAPIClient from "../../../APIClients/UserAPIClient"; +import { SignInLog } from "../../../types/SignInLogsTypes"; + +const SignInLogsPage = (): React.ReactElement => { + const [users, setUsers] = useState([]); + const [numUsers, setNumUsers] = useState(0); + const [resultsPerPage, setResultsPerPage] = useState(25); + const [pageNum, setPageNum] = useState(1); + const [userPageNum, setUserPageNum] = useState(pageNum); + + // Table reference + const tableRef = useRef(null); + + // Change to get filter logs !! + const getUsers = async (pageNumber: number) => { + const data = await UserAPIClient.getUsers({ pageNumber, resultsPerPage }); + + // Reset table scroll + tableRef.current?.scrollTo(0, 0); + + setUsers(data ? data.users : []); + + if (!data || data.users.length === 0) { + setUserPageNum(0); + setPageNum(0); + } else { + setPageNum(pageNumber); + } + }; + + // Create an array of dictionaries + const signInLogs: SignInLog[] = [ + { id: 1, date: "2023-12-03T13:30:00.000Z" , name: "Aathithan Chandrabalan" }, + { id: 1, date: "2023-12-01T12:30:00.000Z" , name: "Phil Dunphy" }, + { id: 1, date: "2023-12-04T15:11:00.000Z" , name: "Connor Bechthold" }, + { id: 1, date: "2023-12-05T19:45:00.000Z" , name: "Bob Cob" }, + { id: 1, date: "2023-12-05T21:23:00.000Z" , name: "Jessica P" }, + ]; + + return ( + + + + + Sign In Logs + + + + + + + ); +}; + +export default SignInLogsPage; diff --git a/frontend/src/components/pages/AdminControls/SignInLogsTable.tsx b/frontend/src/components/pages/AdminControls/SignInLogsTable.tsx new file mode 100644 index 00000000..c1690af3 --- /dev/null +++ b/frontend/src/components/pages/AdminControls/SignInLogsTable.tsx @@ -0,0 +1,62 @@ +import React, { RefObject } from "react"; +import { + Box, + Table, + Tbody, + TableContainer, + Th, + Td, + Thead, + Tr, +} from "@chakra-ui/react"; +import getFormattedDateAndTime from "../../../utils/DateUtils"; +import { SignInLog } from "../../../types/SignInLogsTypes"; + +type Props = { + signInLogs: SignInLog[]; + tableRef: RefObject; +}; + +const SignInLogsTable = ({ + signInLogs, + tableRef, +}: Props): React.ReactElement => { + + return ( + + + + + + + + + + + + {signInLogs.map((log) => { + const dateObj = new Date(log.date); + + const { date, time } = getFormattedDateAndTime(dateObj); + + return ( + + + + + + ); + })} + +
Employee NameDateTime
{`${log.name}`}{date}{time}
+
+
+ ); +}; + +export default SignInLogsTable; diff --git a/frontend/src/components/pages/HomePage/HomePage.tsx b/frontend/src/components/pages/HomePage/HomePage.tsx index a781610e..50a101d4 100644 --- a/frontend/src/components/pages/HomePage/HomePage.tsx +++ b/frontend/src/components/pages/HomePage/HomePage.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; -import { Box, Flex, Spacer } from "@chakra-ui/react"; +import { Box, Flex, Spacer, Spinner, Text } from "@chakra-ui/react"; import Pagination from "../../common/Pagination"; import NavigationBar from "../../common/NavigationBar"; @@ -10,7 +10,7 @@ import SearchAndFilters from "./SearchAndFilters"; import ExportCSVButton from "../../common/ExportCSVButton"; import { BuildingLabel } from "../../../types/BuildingTypes"; import { ResidentLabel } from "../../../types/ResidentTypes"; -import { Tag } from "../../../types/TagsTypes"; +import { TagLabel } from "../../../types/TagTypes"; import { UserLabel } from "../../../types/UserTypes"; import LogRecordAPIClient from "../../../APIClients/LogRecordAPIClient"; @@ -30,7 +30,7 @@ const HomePage = (): React.ReactElement => { const [employees, setEmployees] = useState([]); const [startDate, setStartDate] = useState(); const [endDate, setEndDate] = useState(); - const [tags, setTags] = useState([]); + const [tags, setTags] = useState([]); const [attentionTos, setAttentionTos] = useState([]); const [buildings, setBuildings] = useState([]); const [flagged, setFlagged] = useState(false); @@ -42,6 +42,9 @@ const HomePage = (): React.ReactElement => { const [pageNum, setPageNum] = useState(1); const [userPageNum, setUserPageNum] = useState(pageNum); + // Table Loaded + const [tableLoaded, setTableLoaded] = useState(false) + // Table reference const tableRef = useRef(null); @@ -62,12 +65,14 @@ const HomePage = (): React.ReactElement => { const dateRange = [formatDate(startDate), formatDate(endDate)]; const tagsValues = tags.map((tag) => tag.value); + setTableLoaded(false) + const data = await LogRecordAPIClient.filterLogRecords({ buildingId: buildingIds, employeeId: employeeIds, attnTo: attentionToIds, dateRange: dateRange[0] === "" && dateRange[1] === "" ? [] : dateRange, - residentId: residentsIds, + residents: residentsIds, tags: tagsValues, flagged, resultsPerPage, @@ -85,6 +90,8 @@ const HomePage = (): React.ReactElement => { } else { setPageNum(pageNumber); } + + setTableLoaded(true) }; const countLogRecords = async () => { @@ -102,7 +109,7 @@ const HomePage = (): React.ReactElement => { employeeId: employeeIds, attnTo: attentionToIds, dateRange, - residentId: residentsIds, + residents: residentsIds, tags: tagsValues, flagged, }); @@ -182,23 +189,42 @@ const HomePage = (): React.ReactElement => { setFlagged={setFlagged} /> - - + {!tableLoaded ? ( + + ) : ( + + {numRecords === 0 ? ( + + No results found. + + ) : ( + + + + + )} + + )} ); diff --git a/frontend/src/components/pages/HomePage/LogRecordsTable.tsx b/frontend/src/components/pages/HomePage/LogRecordsTable.tsx index b4eb5018..b18690c7 100644 --- a/frontend/src/components/pages/HomePage/LogRecordsTable.tsx +++ b/frontend/src/components/pages/HomePage/LogRecordsTable.tsx @@ -28,8 +28,13 @@ import AuthContext from "../../../contexts/AuthContext"; import EditLog from "../../forms/EditLog"; import LogRecordAPIClient from "../../../APIClients/LogRecordAPIClient"; import ResidentAPIClient from "../../../APIClients/ResidentAPIClient"; +import TagAPIClient from "../../../APIClients/TagAPIClient"; import UserAPIClient from "../../../APIClients/UserAPIClient"; +import BuildingAPIClient from "../../../APIClients/BuildingAPIClient"; +import { ResidentLabel } from "../../../types/ResidentTypes"; +import { TagLabel } from "../../../types/TagTypes"; import { UserLabel } from "../../../types/UserTypes"; +import { BuildingLabel } from "../../../types/BuildingTypes"; import ConfirmationModal from "../../common/ConfirmationModal"; type Props = { @@ -45,6 +50,28 @@ const DELETE_CONFIRMATION_HEADER = "Delete Log Record"; const DELETE_CONFIRMATION_MESSAGE = "Are you sure you want to delete this log record? Deleting a log record will permanently remove it from your system."; +const formatNote = (note: string) => { + const NOTE_LIMIT = 150; + if (note.length > NOTE_LIMIT) { + return note.substring(0, NOTE_LIMIT).concat("..."); + } + return note; +}; + +const formatList = (strArr: string[]) => { + const strLength = strArr?.length; + if (strLength === 1) { + return strArr[0]; + } + if (strLength === 2) { + return strArr?.join(", "); + } + if (strLength > 2) { + return `${strArr?.slice(0, 2).join(", ")}, ...`; + } + return ""; +}; + const LogRecordsTable = ({ logRecords, tableRef, @@ -57,6 +84,8 @@ const LogRecordsTable = ({ const [showAlert, setShowAlert] = useState(false); + const [buildingOptions, setBuildingOptions] = useState([]); + // Menu states const [deleteOpenMap, setDeleteOpenMap] = useState<{ [key: number]: boolean; @@ -67,7 +96,8 @@ const LogRecordsTable = ({ // Dropdown option states const [employeeOptions, setEmployeeOptions] = useState([]); - const [residentOptions, setResidentOptions] = useState([]); + const [residentOptions, setResidentOptions] = useState([]); + const [tagOptions, setTagOptions] = useState([]); // Handle delete confirmation toggle const handleDeleteToggle = (logId: number) => { @@ -85,7 +115,7 @@ const LogRecordsTable = ({ })); }; - // fetch resident + employee data for log creation + // fetch resident + employee + tag data for log creation const getLogEntryOptions = async () => { const residentsData = await ResidentAPIClient.getResidents({ returnAll: true, @@ -100,6 +130,15 @@ const LogRecordsTable = ({ setResidentOptions(residentLabels); } + const buildingsData = await BuildingAPIClient.getBuildings(); + + if (buildingsData && buildingsData.buildings.length !== 0) { + const buildingLabels: BuildingLabel[] = buildingsData.buildings.map( + (building) => ({ label: building.name!, value: building.id! }), + ); + setBuildingOptions(buildingLabels); + } + const usersData = await UserAPIClient.getUsers({ returnAll: true }); if (usersData && usersData.users.length !== 0) { const userLabels: UserLabel[] = usersData.users @@ -110,6 +149,15 @@ const LogRecordsTable = ({ })); setEmployeeOptions(userLabels); } + + const tagsData = await TagAPIClient.getTags(); + if (tagsData && tagsData.tags.length !== 0) { + const tagLabels: TagLabel[] = tagsData.tags.map((tag) => ({ + label: tag.name, + value: tag.tagId, + })); + setTagOptions(tagLabels); + } }; const deleteLogRecord = async (itemId: number) => { @@ -118,11 +166,8 @@ const LogRecordsTable = ({ } catch (error) { return; } - const newUserPageNum = ( - logRecords.length === 1 - ? userPageNum - 1 - : userPageNum - ); + const newUserPageNum = + logRecords.length === 1 ? userPageNum - 1 : userPageNum; countRecords(); setShowAlert(true); setUserPageNum(newUserPageNum); @@ -156,10 +201,11 @@ const LogRecordsTable = ({ Date Time - Resident + Residents Note Employee Attn To + Tags @@ -175,41 +221,46 @@ const LogRecordsTable = ({ {date} {time} - {record.residentId} + + {formatList(record.residents)} + - {record.note} + {formatNote(record.note)} - {`${record.employee.firstName} ${record.employee.lastName}`} - + {`${record.employee.firstName}`} + {record.attnTo - ? `${record.attnTo.firstName} ${record.attnTo.lastName}` + ? `${record.attnTo.firstName}` : ""} + + {formatList(record.tags)} + {(authenticatedUser?.role === "Admin" || authenticatedUser?.id === record.employee.id) && ( - - } - w="36px" - variant="ghost" - /> - - handleEditToggle(record.logId)} - > - Edit Log Record + + } + w="36px" + variant="ghost" + /> + + handleEditToggle(record.logId)} + > + Edit Log Record - handleDeleteToggle(record.logId)} - > - Delete Log Record + handleDeleteToggle(record.logId)} + > + Delete Log Record - - - )} + + + )} @@ -220,9 +271,11 @@ const LogRecordsTable = ({ toggleClose={() => handleEditToggle(record.logId)} employeeOptions={employeeOptions} residentOptions={residentOptions} + tagOptions={tagOptions} getRecords={getRecords} countRecords={countRecords} setUserPageNum={setUserPageNum} + buildingOptions={buildingOptions} /> >; setStartDate: React.Dispatch>; setEndDate: React.Dispatch>; - setTags: React.Dispatch>; + setTags: React.Dispatch>; setAttentionTos: React.Dispatch>; setBuildings: React.Dispatch>; setFlagged: React.Dispatch>; }; -// Ideally we should be storing this information in the database -const BUILDINGS = [ - { label: "144", value: 1 }, - { label: "362", value: 2 }, - { label: "402", value: 3 }, -]; - -// Replace this with the tags from the db once the API and table are made -const TAGS: Tag[] = [ - { label: "Tag A", value: "A" }, - { label: "Tag B", value: "B" }, - { label: "Tag C", value: "C" }, -]; const SearchAndFilters = ({ residents, @@ -77,11 +66,24 @@ const SearchAndFilters = ({ setBuildings, setFlagged, }: Props): React.ReactElement => { + const [buildingOptions, setBuildingOptions] = useState([]); const [userLabels, setUserLabels] = useState(); const [residentLabels, setResidentLabels] = useState(); + const [tagLabels, setTagLabels] = useState(); const dateChangeToast = CreateToast(); + const getBuildingsOptions = async () => { + const buildingsData = await BuildingAPIClient.getBuildings(); + + if (buildingsData && buildingsData.buildings.length !== 0) { + const buildingLabels: BuildingLabel[] = buildingsData.buildings.map( + (building) => ({ label: building.name!, value: building.id! }), + ); + setBuildingOptions(buildingLabels); + } + }; + const getUsers = async () => { const data = await UserAPIClient.getUsers({ returnAll: true }); const users = data?.users; @@ -110,6 +112,20 @@ const SearchAndFilters = ({ } }; + const getTags = async () => { + const data = await TagAPIClient.getTags(); + const tagsData = data?.tags; + if (tagsData) { + const labels = tagsData.map((tag: Tag) => { + return { + label: tag.name, + value: tag.tagId, + } as TagLabel; + }); + setTagLabels(labels); + } + }; + const handleBuildingChange = ( selectedBuildings: MultiValue, ) => { @@ -162,8 +178,8 @@ const SearchAndFilters = ({ setResidents(mutableSelectedResidents); }; - const handleTagsChange = (selectedTags: MultiValue) => { - const mutableSelectedTags: Tag[] = Array.from(selectedTags); + const handleTagsChange = (selectedTags: MultiValue) => { + const mutableSelectedTags: TagLabel[] = Array.from(selectedTags); setTags(mutableSelectedTags); }; @@ -179,8 +195,10 @@ const SearchAndFilters = ({ }; useEffect(() => { + getBuildingsOptions(); getUsers(); getResidents(); + getTags(); }, []); return ( @@ -281,13 +299,12 @@ const SearchAndFilters = ({ Tags { + const [buildingOptions, setBuildingOptions] = useState([]); const [residents, setResidents] = useState([]); const [numResidents, setNumResidents] = useState(0); const [resultsPerPage, setResultsPerPage] = useState(25); const [pageNum, setPageNum] = useState(1); const [userPageNum, setUserPageNum] = useState(pageNum); + const [tableLoaded, setTableLoaded] = useState(false) + const tableRef = useRef(null); + const getBuildingOptions = async () => { + const data = await BuildingAPIClient.getBuildings(); + + if (data) { + const buildingLabels: BuildingLabel[] = data.buildings.map( + (building) => ({ label: building.name!, value: building.id! }), + ); + setBuildingOptions(buildingLabels); + } + }; + const getResidents = async (pageNumber: number) => { + setTableLoaded(false) const data = await ResidentAPIClient.getResidents({ returnAll: false, pageNumber, @@ -34,6 +51,7 @@ const ResidentDirectory = (): React.ReactElement => { } else { setPageNum(pageNumber); } + setTableLoaded(true) }; const countResidents = async () => { @@ -44,6 +62,7 @@ const ResidentDirectory = (): React.ReactElement => { useEffect(() => { setUserPageNum(1); getResidents(1); + getBuildingOptions(); }, [resultsPerPage]); useEffect(() => { @@ -70,23 +89,44 @@ const ResidentDirectory = (): React.ReactElement => { countResidents={countResidents} />
- - + + {!tableLoaded ? ( + + ) : ( + + {numResidents === 0 ? ( + + No results found. + + ) : ( + + + + + )} + + )} ); diff --git a/frontend/src/components/pages/ResidentDirectory/ResidentDirectoryTable.tsx b/frontend/src/components/pages/ResidentDirectory/ResidentDirectoryTable.tsx index 520956df..6ae3450d 100644 --- a/frontend/src/components/pages/ResidentDirectory/ResidentDirectoryTable.tsx +++ b/frontend/src/components/pages/ResidentDirectory/ResidentDirectoryTable.tsx @@ -15,7 +15,8 @@ import { Tr, } from "@chakra-ui/react"; import { VscKebabVertical } from "react-icons/vsc"; -import { Resident } from "../../../types/ResidentTypes"; +import { BuildingLabel } from "../../../types/BuildingTypes"; +import { Resident, ResidentStatus } from "../../../types/ResidentTypes"; import EditResident from "../../forms/EditResident"; import ResidentAPIClient from "../../../APIClients/ResidentAPIClient"; import getFormattedDateAndTime from "../../../utils/DateUtils"; @@ -25,6 +26,7 @@ import ConfirmationModal from "../../common/ConfirmationModal"; import { convertToDate } from "../../../helper/dateHelpers"; type Props = { + buildingOptions: BuildingLabel[]; residents: Resident[]; tableRef: RefObject; userPageNum: number; @@ -33,19 +35,44 @@ type Props = { countResidents: () => Promise; }; +const getStatusColor = (status: string): string => { + let color = ""; + + switch (status) { + case ResidentStatus.CURRENT: + color = "green.400"; + break; + case ResidentStatus.FUTURE: + color = "teal.400"; + break; + case ResidentStatus.PAST: + color = "gray.300"; + break; + default: + color = "black"; + } + + return color; +}; + const getFormattedDatesAndStatus = (resident: Resident) => { const startDateObj = convertToDate(resident.dateJoined); const startDate = getFormattedDateAndTime(startDateObj, true); let endDate; + let status = ResidentStatus.CURRENT; + const currentDate = new Date(); + currentDate.setHours(0,0,0,0); if (resident.dateLeft != null) { const endDateObj = convertToDate(resident.dateLeft); endDate = getFormattedDateAndTime(endDateObj, true); + if (endDateObj < currentDate) { + status = ResidentStatus.PAST; + } + } + if (currentDate < startDateObj) { + status = ResidentStatus.FUTURE; } - const status = - resident.dateJoined !== null && resident.dateLeft !== null - ? "Past" - : "Current"; return { startDate, endDate, @@ -57,6 +84,7 @@ const DELETE_CONFIRMATION_MESSAGE = "This is a permanent action. Residents can only be deleted if there are no log records associated with them."; const ResidentDirectoryTable = ({ + buildingOptions, residents, tableRef, userPageNum, @@ -64,7 +92,7 @@ const ResidentDirectoryTable = ({ getRecords, countResidents, }: Props): React.ReactElement => { - const { authenticatedUser, setAuthenticatedUser } = useContext(AuthContext); + const { authenticatedUser } = useContext(AuthContext); const [showAlert, setShowAlert] = useState(false); const newToast = CreateToast(); @@ -107,14 +135,11 @@ const ResidentDirectoryTable = ({ "Resident has been deleted successfully.", "success", ); - const newUserPageNum = ( - residents.length === 1 - ? userPageNum - 1 - : userPageNum - ); - countResidents() - getRecords(newUserPageNum) - setUserPageNum(newUserPageNum) + const newUserPageNum = + residents.length === 1 ? userPageNum - 1 : userPageNum; + countResidents(); + getRecords(newUserPageNum); + setUserPageNum(newUserPageNum); setIsDeleteModalOpen(false); } setShowAlert(true); @@ -140,7 +165,7 @@ const ResidentDirectoryTable = ({ Resident - Status + Status Building Residency Start Date Residency End Date @@ -155,8 +180,20 @@ const ResidentDirectoryTable = ({ // TODO: Remove non-null assertion from residentId return ( - {resident.residentId!} - {status} + {resident.residentId!} + + + {status} + + {resident.building.name} {startDate.date} {endDate ? endDate.date : ""} @@ -188,6 +225,7 @@ const ResidentDirectoryTable = ({ {editingResident && ( { employee: `"${logRecord.employee.firstName} ${logRecord.employee.lastName}"`, flagged: logRecord.flagged, note: `"${logRecord.note}"`, - residentId: `"${logRecord.residentId}"`, + residents: `"${logRecord.residents.join(", ")}"`, tags: logRecord.tags != null ? logRecord.tags.join("; ") : "", }; }; @@ -29,7 +29,7 @@ const CSVConverter = (data: LogRecord[]): boolean => { "employee", "flagged", "note", - "residentId", + "residents", "tags", ]; csvRows.push(headers.join(",")); diff --git a/frontend/src/helper/authErrorMessage.ts b/frontend/src/helper/authErrorMessage.ts deleted file mode 100644 index c2258c84..00000000 --- a/frontend/src/helper/authErrorMessage.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { AxiosError } from "axios"; - -// Helper to get login error message -const getLoginErrMessage = (axiosErrRes: AxiosError["response"]): string => { - if (axiosErrRes && axiosErrRes.data && axiosErrRes.data.error) { - return axiosErrRes.data.error; - } - return "Error logging in. Please try again later."; -}; - -export default getLoginErrMessage; diff --git a/frontend/src/helper/error.ts b/frontend/src/helper/error.ts new file mode 100644 index 00000000..b0943b43 --- /dev/null +++ b/frontend/src/helper/error.ts @@ -0,0 +1,20 @@ +import { AxiosError } from "axios"; +import { AuthTokenResponse, AuthFlow } from "../types/AuthTypes"; +import { AuthErrorResponse, ResidentErrorResponse } from '../types/ErrorTypes' + +export const getAuthErrMessage = (axiosErrRes: AxiosError["response"], flow: AuthFlow): string => { + if (axiosErrRes && axiosErrRes.data && axiosErrRes.data.error) { + return axiosErrRes.data.error; + } + return `Error ${flow === 'LOGIN' ? "logging in" : "signing up"}. Please try again later.`; +} + +export const isAuthErrorResponse = ( + res: string | AuthTokenResponse | AuthErrorResponse, +): res is AuthErrorResponse => { + return res !== null && typeof res !== 'string' && "errCode" in res; +}; + +export const isResidentErrorResponse = (res: boolean | ResidentErrorResponse) : res is ResidentErrorResponse => { + return (typeof res !== 'boolean' && 'errMessage' in res); +} diff --git a/frontend/src/theme/common/spinnerStyles.tsx b/frontend/src/theme/common/spinnerStyles.tsx new file mode 100644 index 00000000..5ba5be4d --- /dev/null +++ b/frontend/src/theme/common/spinnerStyles.tsx @@ -0,0 +1,14 @@ +import type { ComponentStyleConfig } from "@chakra-ui/theme"; + +// Spinner styling +const Spinner: ComponentStyleConfig = { + baseStyle: { + color: "teal.400", + marginTop: "5%", + }, + defaultProps: { + size: "xl", + }, +}; + +export default Spinner; diff --git a/frontend/src/theme/common/tableStyles.tsx b/frontend/src/theme/common/tableStyles.tsx index 5da1320e..e07cb9b9 100644 --- a/frontend/src/theme/common/tableStyles.tsx +++ b/frontend/src/theme/common/tableStyles.tsx @@ -17,7 +17,6 @@ const Table: ComponentStyleConfig = { thead: { position: "sticky", top: 0, - zIndex: "docked", }, }, }, diff --git a/frontend/src/theme/forms/inputStyles.tsx b/frontend/src/theme/forms/inputStyles.tsx index 9211c3d3..de142516 100644 --- a/frontend/src/theme/forms/inputStyles.tsx +++ b/frontend/src/theme/forms/inputStyles.tsx @@ -39,7 +39,7 @@ const Input: ComponentStyleConfig = { border: "1px solid", borderColor: "gray.100", borderRadius: "4px", - height: "7vh", + height: "8vh", fontWeight: "400", fontSize: "22px", fontFamily: "DM Sans", diff --git a/frontend/src/theme/index.tsx b/frontend/src/theme/index.tsx index c394cf8e..882ef624 100644 --- a/frontend/src/theme/index.tsx +++ b/frontend/src/theme/index.tsx @@ -3,6 +3,7 @@ import { extendTheme } from "@chakra-ui/react"; import colors from "./colors"; import fontStyles from "./fontStyles"; import Button from "./common/buttonStyles"; +import Spinner from "./common/spinnerStyles"; import Table from "./common/tableStyles"; import Text from "./common/textStyles"; import { Input, Textarea } from "./forms/inputStyles"; @@ -23,6 +24,7 @@ const customTheme = extendTheme({ FormLabel, Modal, Text, + Spinner, }, }); diff --git a/frontend/src/types/AuthTypes.ts b/frontend/src/types/AuthTypes.ts index 4e55e7d6..b0d782be 100644 --- a/frontend/src/types/AuthTypes.ts +++ b/frontend/src/types/AuthTypes.ts @@ -16,6 +16,7 @@ export type AuthenticatedUser = { email: string; role: UserRole; accessToken: string; + verified: boolean; }; export type DecodedJWT = @@ -27,3 +28,5 @@ export type ErrorResponse = { errCode: number; errMessage: string; }; + +export type AuthFlow = 'LOGIN' | 'SIGNUP'; diff --git a/frontend/src/types/BuildingTypes.ts b/frontend/src/types/BuildingTypes.ts index a2b6b752..8b5b0e4c 100644 --- a/frontend/src/types/BuildingTypes.ts +++ b/frontend/src/types/BuildingTypes.ts @@ -3,3 +3,13 @@ export type BuildingLabel = { label: string; value: number; }; + +export type Building = { + id: number; + address: string; + name: string; +}; + +export type GetBuildingsResponse = { + buildings: Building[]; +} | null; diff --git a/frontend/src/types/CSVLog.ts b/frontend/src/types/CSVLog.ts index a091904b..8db488a3 100644 --- a/frontend/src/types/CSVLog.ts +++ b/frontend/src/types/CSVLog.ts @@ -5,6 +5,6 @@ export type CSVLog = { employee: string; flagged: boolean; note: string; - residentId: string; + residents: string; tags: string; }; diff --git a/frontend/src/types/ErrorTypes.ts b/frontend/src/types/ErrorTypes.ts new file mode 100644 index 00000000..aafa8698 --- /dev/null +++ b/frontend/src/types/ErrorTypes.ts @@ -0,0 +1,8 @@ +export type ResidentErrorResponse = { + errMessage: string; +} + +export type AuthErrorResponse = { + errCode: number; + errMessage: string; +}; diff --git a/frontend/src/types/LogRecordTypes.ts b/frontend/src/types/LogRecordTypes.ts index 04a8cde9..55af3672 100644 --- a/frontend/src/types/LogRecordTypes.ts +++ b/frontend/src/types/LogRecordTypes.ts @@ -17,7 +17,7 @@ export type LogRecord = { datetime: string; flagged: boolean; note: string; - residentId: string; + residents: string[]; tags: string[]; }; @@ -37,7 +37,7 @@ export type PostLogRecordsResponse = Pick< | "employee" | "flagged" | "note" - | "residentId" + | "residents" > | null; export type CountLogRecordFilters = { @@ -45,18 +45,18 @@ export type CountLogRecordFilters = { employeeId?: number[]; attnTo?: number[]; dateRange?: string[]; - residentId?: number[]; - tags?: string[]; + residents?: number[]; + tags?: number[]; flagged?: boolean; }; export type CreateLogRecordParams = { employeeId: number; - residentId: number; + residents: number[]; datetime: Date; flagged: boolean; note: string; - tags: string[]; + tags: number[]; buildingId: number; attnTo?: number; }; @@ -64,11 +64,11 @@ export type CreateLogRecordParams = { export type EditLogRecordParams = { logId: number; employeeId: number; - residentId: number; + residents: number[]; datetime: Date; flagged: boolean; note: string; - tags: string[]; + tags: number[]; buildingId: number; attnTo?: number; }; diff --git a/frontend/src/types/ResidentTypes.ts b/frontend/src/types/ResidentTypes.ts index 1604f5c0..8bc21607 100644 --- a/frontend/src/types/ResidentTypes.ts +++ b/frontend/src/types/ResidentTypes.ts @@ -34,3 +34,9 @@ export type CreateResidentParams = Omit< export type EditResidentParams = Omit & { buildingId: number; }; + +export enum ResidentStatus { + FUTURE = "Future", + PAST = "Past", + CURRENT = "Current", +} diff --git a/frontend/src/types/SignInLogsTypes.ts b/frontend/src/types/SignInLogsTypes.ts new file mode 100644 index 00000000..5019957c --- /dev/null +++ b/frontend/src/types/SignInLogsTypes.ts @@ -0,0 +1,5 @@ +export type SignInLog = { + id: number, + name: string, + date: string +} \ No newline at end of file diff --git a/frontend/src/types/TagTypes.ts b/frontend/src/types/TagTypes.ts new file mode 100644 index 00000000..2cf85505 --- /dev/null +++ b/frontend/src/types/TagTypes.ts @@ -0,0 +1,14 @@ +// TODO: Change this type to the values of the tags we want to get from the DB +export type Tag = { + tagId: number; + name: string; +}; + +export type TagLabel = { + label: string; + value: number; +}; + +export type GetTagsResponse = { + tags: Tag[]; +} | null; diff --git a/frontend/src/types/TagsTypes.ts b/frontend/src/types/TagsTypes.ts deleted file mode 100644 index 3fd8309c..00000000 --- a/frontend/src/types/TagsTypes.ts +++ /dev/null @@ -1,5 +0,0 @@ -// TODO: Change this type to the values of the tags we want to get from the DB -export type Tag = { - label: string; - value: string; -}; diff --git a/scripts/exec-db.sh b/scripts/exec-db.sh old mode 100644 new mode 100755 diff --git a/seeding/create-user.sh b/seeding/invite-user.sh similarity index 71% rename from seeding/create-user.sh rename to seeding/invite-user.sh index ebee86c2..fb3dae74 100644 --- a/seeding/create-user.sh +++ b/seeding/invite-user.sh @@ -12,19 +12,16 @@ run_sql_script " INSERT INTO users ( first_name, last_name, - auth_id, - role + role, + user_status, + email ) -SELECT +VALUES ( '$FIRST_NAME', '$LAST_NAME', - '$AUTH_ID', - '$ROLE' -WHERE NOT EXISTS ( - SELECT 1 - FROM users - WHERE first_name = '$FIRST_NAME' - AND last_name = '$LAST_NAME' + '$ROLE', + 'Invited', + '$EMAIL' ); "