Skip to content

Commit

Permalink
Merge branch 'main' into create-login-frontend
Browse files Browse the repository at this point in the history
  • Loading branch information
aliicezhao committed Nov 29, 2024
2 parents edbc6d5 + befb6ec commit 33cfe1f
Show file tree
Hide file tree
Showing 17 changed files with 569 additions and 13 deletions.
2 changes: 2 additions & 0 deletions backend/typescript/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const MIN_BEHAVIOUR_LEVEL = 1;
export const MAX_BEHAVIOUR_LEVEL = 4;
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,6 @@ export const up: Migration = async ({ context: sequelize }) => {
},
level: {
type: DataType.INTEGER,
validate: {
min: 1,
max: 4,
},
allowNull: false,
},
description: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { DataType } from "sequelize-typescript";

import { Migration } from "../umzug";
import { MAX_BEHAVIOUR_LEVEL, MIN_BEHAVIOUR_LEVEL } from "../constants";

const TABLE_NAME = "user_behaviours";
const CONSTRAINT_NAME = "unique_user_behaviour_skill";
const CONSTRAINT_NAME_2 = "max_level_interval";

export const up: Migration = async ({ context: sequelize }) => {
await sequelize.getQueryInterface().createTable(TABLE_NAME, {
id: {
type: DataType.INTEGER,
allowNull: false,
primaryKey: true,
autoIncrement: true,
},
user_id: {
type: DataType.INTEGER,
allowNull: false,
references: {
model: "users",
key: "id",
},
},
behaviour_id: {
type: DataType.INTEGER,
allowNull: false,
references: {
model: "behaviours",
key: "id",
},
},
max_level: {
type: DataType.INTEGER,
allowNull: false,
},
});

await sequelize.getQueryInterface().addConstraint(TABLE_NAME, {
fields: ["behaviour_id", "user_id"],
type: "unique",
name: CONSTRAINT_NAME,
});

await sequelize.query(
`ALTER TABLE ${TABLE_NAME} ADD CONSTRAINT ${CONSTRAINT_NAME_2}
CHECK (max_level BETWEEN ${MIN_BEHAVIOUR_LEVEL} AND ${MAX_BEHAVIOUR_LEVEL});`,
);
};

export const down: Migration = async ({ context: sequelize }) => {
await sequelize
.getQueryInterface()
.removeConstraint(TABLE_NAME, CONSTRAINT_NAME);

await sequelize.query(
`ALTER TABLE ${TABLE_NAME} DROP CONSTRAINT ${CONSTRAINT_NAME_2};`,
);

await sequelize.getQueryInterface().dropTable(TABLE_NAME);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { DataType } from "sequelize-typescript";
import { Migration } from "../umzug";
import { MAX_BEHAVIOUR_LEVEL, MIN_BEHAVIOUR_LEVEL } from "../constants";

const TABLE_NAME = "pet_behaviours";
const CONSTRAINT_NAME = "unique_pet_behaviour";
const CONSTRAINT_NAME_2 = "skill_level_interval";

export const up: Migration = async ({ context: sequelize }) => {
await sequelize.getQueryInterface().addColumn(TABLE_NAME, "is_highlighted", {
type: DataType.BOOLEAN,
allowNull: true,
defaultValue: false,
});

await sequelize.getQueryInterface().addConstraint(TABLE_NAME, {
fields: ["behaviour_id", "pet_id"],
type: "unique",
name: CONSTRAINT_NAME,
});

await sequelize.query(
`ALTER TABLE ${TABLE_NAME} ADD CONSTRAINT ${CONSTRAINT_NAME_2}
CHECK (skill_level BETWEEN ${MIN_BEHAVIOUR_LEVEL} AND ${MAX_BEHAVIOUR_LEVEL});`,
);
};

export const down: Migration = async ({ context: sequelize }) => {
await sequelize
.getQueryInterface()
.removeConstraint(TABLE_NAME, CONSTRAINT_NAME);

await sequelize
.getQueryInterface()
.removeColumn(TABLE_NAME, "is_highlighted");

await sequelize.query(
`ALTER TABLE ${TABLE_NAME} DROP CONSTRAINT ${CONSTRAINT_NAME_2};`,
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { DataType } from "sequelize-typescript";
import { Migration } from "../umzug";
import { MAX_BEHAVIOUR_LEVEL, MIN_BEHAVIOUR_LEVEL } from "../constants";

const TABLE_NAME = "behaviour_level_details";
const CONSTRAINT_NAME = "level_interval";

export const up: Migration = async ({ context: sequelize }) => {
await sequelize.query(
`ALTER TABLE ${TABLE_NAME} ADD CONSTRAINT ${CONSTRAINT_NAME}
CHECK (level BETWEEN ${MIN_BEHAVIOUR_LEVEL} AND ${MAX_BEHAVIOUR_LEVEL});`,
);
};

export const down: Migration = async ({ context: sequelize }) => {
await sequelize.query(
`ALTER TABLE ${TABLE_NAME} DROP CONSTRAINT ${CONSTRAINT_NAME};`,
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,30 @@ import {
Table,
ForeignKey,
BelongsTo,
DataType,
} from "sequelize-typescript";
import Behaviour from "./behaviour.model";
import Pet from "./pet.model";

@Table({ timestamps: false, tableName: "pet_behaviours" })
export default class PetBehaviour extends Model {
@ForeignKey(() => Pet)
@Column({})
@Column({ type: DataType.INTEGER, allowNull: false })
pet_id!: number;

@BelongsTo(() => Pet)
pet!: Pet;

@ForeignKey(() => Behaviour)
@Column({})
@Column({ type: DataType.INTEGER, allowNull: false })
behaviour_id!: number;

@BelongsTo(() => Behaviour)
behaviour!: Behaviour;

@Column({})
@Column({ type: DataType.INTEGER, allowNull: false })
skill_level!: number;

@Column({ type: DataType.BOOLEAN, allowNull: true })
is_highlighted!: boolean;
}
30 changes: 30 additions & 0 deletions backend/typescript/models/userBehaviour.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {
Column,
Model,
Table,
ForeignKey,
BelongsTo,
DataType,
} from "sequelize-typescript";
import Behaviour from "./behaviour.model";
import User from "./user.model";

@Table({ timestamps: false, tableName: "user_behaviours" })
export default class UserBehaviour extends Model {
@ForeignKey(() => User)
@Column({ type: DataType.INTEGER, allowNull: false })
user_id!: number;

@BelongsTo(() => User)
user!: User;

@ForeignKey(() => Behaviour)
@Column({ type: DataType.INTEGER, allowNull: false })
behaviour_id!: number;

@BelongsTo(() => Behaviour)
behaviour!: Behaviour;

@Column({ type: DataType.INTEGER, allowNull: false })
max_level!: number;
}
19 changes: 17 additions & 2 deletions frontend/src/APIClients/UserAPIClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { User } from "../types/UserTypes";
import { User, CreateUserDTO } from "../types/UserTypes";
import AUTHENTICATED_USER_KEY from "../constants/AuthConstants";
import baseAPIClient from "./BaseAPIClient";
import { getLocalStorageObjProperty } from "../utils/LocalStorageUtils";
Expand All @@ -18,4 +18,19 @@ const get = async (): Promise<User[]> => {
}
};

export default { get };
const create = async (formData: CreateUserDTO): Promise<CreateUserDTO> => {
const bearerToken = `Bearer ${getLocalStorageObjProperty(
AUTHENTICATED_USER_KEY,
"accessToken",
)}`;
try {
const { data } = await baseAPIClient.post("/users", formData, {
headers: { Authorization: bearerToken },
});
return data;
} catch (error) {
throw new Error(`Failed to create user: ${error}`);
}
};

export default { get, create };
9 changes: 8 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
import Default from "./components/pages/Default";
import Login from "./components/auth/Login";
import Signup from "./components/auth/Signup";
import ForgotPasswordPage from "./components/pages/ForgotPassword";
import PrivateRoute from "./components/auth/PrivateRoute";
import CreatePage from "./components/pages/CreatePage";
import PetListPage from "./components/pages/PetListPage";
Expand Down Expand Up @@ -62,10 +63,16 @@ const App = (): React.ReactElement => {
<Switch>
<Route exact path={Routes.LOGIN_PAGE} component={Login} />
<Route exact path={Routes.SIGNUP_PAGE} component={Signup} />
<Route
<PrivateRoute
exact
path={Routes.CREATE_PASSWORD_PAGE}
component={CreatePasswordPage}
allowedRoles={AuthConstants.ALL_ROLES}
/>
<Route
exact
path={Routes.FORGOT_PASSWORD_PAGE}
component={ForgotPasswordPage}
/>
<PrivateRoute
exact
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/components/common/StatusMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from "react";
import { Text } from "@chakra-ui/react";

interface StatusMessageProps {
message: string;
color?: string;
}

const StatusMessage = ({
message,
color = "blue.700",
}: StatusMessageProps): React.ReactElement => {
return (
<Text color={color} textAlign="center" lineHeight="120%" marginTop="16px">
{message}
</Text>
);
};

export default StatusMessage;
102 changes: 102 additions & 0 deletions frontend/src/components/crud/AddUserFormModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React, { useState } from "react";
import { JSONSchema7 } from "json-schema";
import { Form } from "@rjsf/bootstrap-4";
import { IChangeEvent, ISubmitEvent } from "@rjsf/core";

export interface AddUserRequest {
firstName: string;
lastName: string;
phoneNumber: string;
email: string;
role: "Administrator" | "Animal Behaviourist" | "Staff" | "Volunteer";
}

interface AddUserFormModalProps {
onSubmit: (formData: AddUserRequest) => Promise<void>;
}

const userSchema: JSONSchema7 = {
title: "Invite a user",
description: "Enter user details to send an invite",
type: "object",
required: ["firstName", "lastName", "phoneNumber", "email", "role"],
properties: {
firstName: { type: "string", title: "First Name" },
lastName: { type: "string", title: "Last Name" },
phoneNumber: { type: "string", title: "Phone Number" },
email: { type: "string", format: "email", title: "Email" },
role: {
type: "string",
title: "Role",
enum: ["Administrator", "Animal Behaviourist", "Staff", "Volunteer"],
default: "Staff",
},
},
};

const uiSchema = {
role: {
"ui:widget": "select",
},
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const validate = (formData: AddUserRequest, errors: any) => {
const phoneRegex = /^\d{3}-\d{3}-\d{4}$/;
const phoneRegex2 = /^\d{10}$/;
if (
!phoneRegex.test(formData.phoneNumber) &&
!phoneRegex2.test(formData.phoneNumber)
) {
errors.phoneNumber.addError("Phone number must be in xxx-xxx-xxxx format.");
}
if (!formData.email.includes("@")) {
errors.email.addError("Email must be in address@domain format.");
}
return errors;
};

const AddUserFormModal = ({
onSubmit,
}: AddUserFormModalProps): React.ReactElement => {
const [formFields, setFormFields] = useState<AddUserRequest | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const handleSubmit = async ({ formData }: ISubmitEvent<AddUserRequest>) => {
setLoading(true);
setError(null);
try {
await onSubmit(formData);
setFormFields(null);
} catch (err) {
setError("An error occurred while sending the invite.");
} finally {
setLoading(false);
}
};

return (
<div>
<Form
formData={formFields}
schema={userSchema}
uiSchema={uiSchema}
validate={validate}
onChange={({ formData }: IChangeEvent<AddUserRequest>) =>
setFormFields(formData)
}
onSubmit={handleSubmit}
>
<div style={{ textAlign: "center" }}>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? "Sending..." : "Send Invite"}
</button>
</div>
</Form>
{error && <p style={{ color: "red" }}>{error}</p>}
</div>
);
};

export default AddUserFormModal;
Loading

0 comments on commit 33cfe1f

Please sign in to comment.