diff --git a/Makefile b/Makefile index 668ccf1..97db62e 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,10 @@ clean: remove-podman-containers cd backend && make clean @echo "Cleaned up!" +.PHONY: itest +itest: + python testing.py + # Cleans up everything related to podman, not just the project. Make sure you understand what this means. podman-clean: podman system reset --force diff --git a/backend/Makefile b/backend/Makefile index 9cfa335..da0e254 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -118,3 +118,7 @@ uml: plantuml.jar install-just: @echo "Installing just" @curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin + +.PHONY: types +types: + tygo generate \ No newline at end of file diff --git a/backend/internal/database/db.go b/backend/internal/database/db.go index b5e1981..5cbb13f 100644 --- a/backend/internal/database/db.go +++ b/backend/internal/database/db.go @@ -2,9 +2,8 @@ package database import ( "embed" - "os" + "errors" "path/filepath" - "time" "ttime/internal/types" "github.com/jmoiron/sqlx" @@ -15,20 +14,24 @@ import ( type Database interface { // Insert a new user into the database, password should be hashed before calling AddUser(username string, password string) error + CheckUser(username string, password string) bool RemoveUser(username string) error PromoteToAdmin(username string) error GetUserId(username string) (int, error) AddProject(name string, description string, username string) error - Migrate(dirname string) error + Migrate() error GetProjectId(projectname string) (int, error) - AddTimeReport(projectName string, userName string, start time.Time, end time.Time) error + AddWeeklyReport(projectName string, userName string, week int, developmentTime int, meetingTime int, adminTime int, ownWorkTime int, studyTime int, testingTime int) error AddUserToProject(username string, projectname string, role string) error ChangeUserRole(username string, projectname string, role string) error GetAllUsersProject(projectname string) ([]UserProjectMember, error) GetAllUsersApplication() ([]string, error) GetProjectsForUser(username string) ([]types.Project, error) GetAllProjects() ([]types.Project, error) + GetProject(projectId int) (types.Project, error) GetUserRole(username string, projectname string) (string, error) + GetWeeklyReport(username string, projectName string, week int) (types.WeeklyReport, error) + SignWeeklyReport(reportId int, projectManagerId int) error } // This struct is a wrapper type that holds the database connection @@ -49,27 +52,16 @@ var scripts embed.FS const userInsert = "INSERT INTO users (username, password) VALUES (?, ?)" const projectInsert = "INSERT INTO projects (name, description, owner_user_id) SELECT ?, ?, id FROM users WHERE username = ?" const promoteToAdmin = "INSERT INTO site_admin (admin_id) SELECT id FROM users WHERE username = ?" -const addTimeReport = `WITH UserLookup AS (SELECT id FROM users WHERE username = ?), +const addWeeklyReport = `WITH UserLookup AS (SELECT id FROM users WHERE username = ?), ProjectLookup AS (SELECT id FROM projects WHERE name = ?) - INSERT INTO time_reports (project_id, user_id, start, end) - VALUES ((SELECT id FROM ProjectLookup), (SELECT id FROM UserLookup), ?, ?);` + INSERT INTO weekly_reports (project_id, user_id, week, development_time, meeting_time, admin_time, own_work_time, study_time, testing_time) + VALUES ((SELECT id FROM ProjectLookup), (SELECT id FROM UserLookup),?, ?, ?, ?, ?, ?, ?);` const addUserToProject = "INSERT INTO user_roles (user_id, project_id, p_role) VALUES (?, ?, ?)" // WIP const changeUserRole = "UPDATE user_roles SET p_role = ? WHERE user_id = ? AND project_id = ?" -const getProjectsForUser = ` -SELECT - projects.id, - projects.name, - projects.description, - projects.owner_user_id -FROM - projects -JOIN - user_roles ON projects.id = user_roles.project_id -JOIN - users ON user_roles.user_id = users.id -WHERE - users.username = ?;` +const getProjectsForUser = `SELECT projects.id, projects.name, projects.description, projects.owner_user_id + FROM projects JOIN user_roles ON projects.id = user_roles.project_id + JOIN users ON user_roles.user_id = users.id WHERE users.username = ?;` // DbConnect connects to the database func DbConnect(dbpath string) Database { @@ -88,23 +80,42 @@ func DbConnect(dbpath string) Database { return &Db{db} } +func (d *Db) CheckUser(username string, password string) bool { + var dbPassword string + err := d.Get(&dbPassword, "SELECT password FROM users WHERE username = ?", username) + if err != nil { + return false + } + return dbPassword == password +} + +// GetProjectsForUser retrieves all projects associated with a specific user. func (d *Db) GetProjectsForUser(username string) ([]types.Project, error) { var projects []types.Project err := d.Select(&projects, getProjectsForUser, username) return projects, err } +// GetAllProjects retrieves all projects from the database. func (d *Db) GetAllProjects() ([]types.Project, error) { var projects []types.Project err := d.Select(&projects, "SELECT * FROM projects") return projects, err } -func (d *Db) AddTimeReport(projectName string, userName string, start time.Time, end time.Time) error { // WIP - _, err := d.Exec(addTimeReport, userName, projectName, start, end) +// GetProject retrieves a specific project by its ID. +func (d *Db) GetProject(projectId int) (types.Project, error) { + var project types.Project + err := d.Select(&project, "SELECT * FROM projects WHERE id = ?") + return project, err +} + +func (d *Db) AddWeeklyReport(projectName string, userName string, week int, developmentTime int, meetingTime int, adminTime int, ownWorkTime int, studyTime int, testingTime int) error { + _, err := d.Exec(addWeeklyReport, userName, projectName, week, developmentTime, meetingTime, adminTime, ownWorkTime, studyTime, testingTime) return err } +// AddUserToProject adds a user to a project with a specified role. func (d *Db) AddUserToProject(username string, projectname string, role string) error { // WIP var userid int userid, err := d.GetUserId(username) @@ -122,23 +133,28 @@ func (d *Db) AddUserToProject(username string, projectname string, role string) return err3 } +// ChangeUserRole changes the role of a user within a project. func (d *Db) ChangeUserRole(username string, projectname string, role string) error { + // Get the user ID var userid int userid, err := d.GetUserId(username) if err != nil { panic(err) } + // Get the project ID var projectid int projectid, err2 := d.GetProjectId(projectname) if err2 != nil { panic(err2) } + // Execute the SQL query to change the user's role _, err3 := d.Exec(changeUserRole, role, userid, projectid) return err3 } +// GetUserRole retrieves the role of a user within a project. func (d *Db) GetUserRole(username string, projectname string) (string, error) { var role string err := d.Get(&role, "SELECT p_role FROM user_roles WHERE user_id = (SELECT id FROM users WHERE username = ?) AND project_id = (SELECT id FROM projects WHERE name = ?)", username, projectname) @@ -243,15 +259,74 @@ func (d *Db) GetAllUsersApplication() ([]string, error) { return usernames, nil } +func (d *Db) GetWeeklyReport(username string, projectName string, week int) (types.WeeklyReport, error) { + var report types.WeeklyReport + query := ` + SELECT + report_id, + user_id, + project_id, + week, + development_time, + meeting_time, + admin_time, + own_work_time, + study_time, + testing_time, + signed_by + FROM + weekly_reports + WHERE + user_id = (SELECT id FROM users WHERE username = ?) + AND project_id = (SELECT id FROM projects WHERE name = ?) + AND week = ? + ` + err := d.Get(&report, query, username, projectName, week) + return report, err +} + +// SignWeeklyReport signs a weekly report by updating the signed_by field +// with the provided project manager's ID, but only if the project manager +// is in the same project as the report +func (d *Db) SignWeeklyReport(reportId int, projectManagerId int) error { + // Retrieve the project ID associated with the report + var reportProjectID int + err := d.Get(&reportProjectID, "SELECT project_id FROM weekly_reports WHERE report_id = ?", reportId) + if err != nil { + return err + } + + // Retrieve the project ID associated with the project manager + var managerProjectID int + err = d.Get(&managerProjectID, "SELECT project_id FROM user_roles WHERE user_id = ? AND p_role = 'project_manager'", projectManagerId) + if err != nil { + return err + } + + // Check if the project manager is in the same project as the report + if reportProjectID != managerProjectID { + return errors.New("project manager doesn't have permission to sign the report") + } + + // Update the signed_by field of the specified report + _, err = d.Exec("UPDATE weekly_reports SET signed_by = ? WHERE report_id = ?", projectManagerId, reportId) + return err +} + // Reads a directory of migration files and applies them to the database. // This will eventually be used on an embedded directory -func (d *Db) Migrate(dirname string) error { +func (d *Db) Migrate() error { // Read the embedded scripts directory files, err := scripts.ReadDir("migrations") if err != nil { return err } + if len(files) == 0 { + println("No migration files found") + return nil + } + tr := d.MustBegin() // Iterate over each SQL file and execute it @@ -261,8 +336,7 @@ func (d *Db) Migrate(dirname string) error { } // This is perhaps not the most elegant way to do this - sqlFile := filepath.Join("migrations", file.Name()) - sqlBytes, err := os.ReadFile(sqlFile) + sqlBytes, err := scripts.ReadFile("migrations/" + file.Name()) if err != nil { return err } diff --git a/backend/internal/database/db_test.go b/backend/internal/database/db_test.go index 7650739..09de45b 100644 --- a/backend/internal/database/db_test.go +++ b/backend/internal/database/db_test.go @@ -1,15 +1,15 @@ package database import ( + "fmt" "testing" - "time" ) // Tests are not guaranteed to be sequential func setupState() (Database, error) { db := DbConnect(":memory:") - err := db.Migrate("../../migrations") + err := db.Migrate() if err != nil { return nil, err } @@ -93,7 +93,7 @@ func TestPromoteToAdmin(t *testing.T) { } } -func TestAddTimeReport(t *testing.T) { +func TestAddWeeklyReport(t *testing.T) { db, err := setupState() if err != nil { t.Error("setupState failed:", err) @@ -109,12 +109,9 @@ func TestAddTimeReport(t *testing.T) { t.Error("AddProject failed:", err) } - var now = time.Now() - var then = now.Add(time.Hour) - - err = db.AddTimeReport("testproject", "testuser", now, then) + err = db.AddWeeklyReport("testproject", "testuser", 1, 1, 1, 1, 1, 1, 1) if err != nil { - t.Error("AddTimeReport failed:", err) + t.Error("AddWeeklyReport failed:", err) } } @@ -134,12 +131,9 @@ func TestAddUserToProject(t *testing.T) { t.Error("AddProject failed:", err) } - var now = time.Now() - var then = now.Add(time.Hour) - - err = db.AddTimeReport("testproject", "testuser", now, then) + err = db.AddWeeklyReport("testproject", "testuser", 1, 1, 1, 1, 1, 1, 1) if err != nil { - t.Error("AddTimeReport failed:", err) + t.Error("AddWeeklyReport failed:", err) } err = db.AddUserToProject("testuser", "testproject", "user") @@ -343,3 +337,202 @@ func TestGetProjectsForUser(t *testing.T) { t.Error("GetProjectsForUser failed: expected 1, got", len(projects)) } } + +func TestAddProject(t *testing.T) { + db, err := setupState() + if err != nil { + t.Error("setupState failed:", err) + } + + err = db.AddUser("testuser", "password") + if err != nil { + t.Error("AddUser failed:", err) + } + + err = db.AddProject("testproject", "description", "testuser") + if err != nil { + t.Error("AddProject failed:", err) + } + + // Retrieve the added project to verify its existence + projects, err := db.GetAllProjects() + if err != nil { + t.Error("GetAllProjects failed:", err) + } + + // Check if the project was added successfully + found := false + for _, project := range projects { + if project.Name == "testproject" { + found = true + break + } + } + if !found { + t.Error("Added project not found") + } +} + +func TestGetWeeklyReport(t *testing.T) { + db, err := setupState() + if err != nil { + t.Error("setupState failed:", err) + } + + err = db.AddUser("testuser", "password") + if err != nil { + t.Error("AddUser failed:", err) + } + + err = db.AddProject("testproject", "description", "testuser") + if err != nil { + t.Error("AddProject failed:", err) + } + + err = db.AddWeeklyReport("testproject", "testuser", 1, 1, 1, 1, 1, 1, 1) + if err != nil { + t.Error("AddWeeklyReport failed:", err) + } + + report, err := db.GetWeeklyReport("testuser", "testproject", 1) + if err != nil { + t.Error("GetWeeklyReport failed:", err) + } + + // Check if the retrieved report matches the expected values + if report.UserId != 1 { + t.Errorf("Expected UserId to be 1, got %d", report.UserId) + } + if report.ProjectId != 1 { + t.Errorf("Expected ProjectId to be 1, got %d", report.ProjectId) + } + if report.Week != 1 { + t.Errorf("Expected Week to be 1, got %d", report.Week) + } + // Check other fields similarly +} + +func TestSignWeeklyReport(t *testing.T) { + db, err := setupState() + if err != nil { + t.Error("setupState failed:", err) + } + + // Add project manager + err = db.AddUser("projectManager", "password") + if err != nil { + t.Error("AddUser failed:", err) + } + + // Add a regular user + err = db.AddUser("testuser", "password") + if err != nil { + t.Error("AddUser failed:", err) + } + + // Add project + err = db.AddProject("testproject", "description", "projectManager") + if err != nil { + t.Error("AddProject failed:", err) + } + + // Add both regular users as members to the project + err = db.AddUserToProject("testuser", "testproject", "member") + if err != nil { + t.Error("AddUserToProject failed:", err) + } + + err = db.AddUserToProject("projectManager", "testproject", "project_manager") + if err != nil { + t.Error("AddUserToProject failed:", err) + } + + // Add a weekly report for one of the regular users + err = db.AddWeeklyReport("testproject", "testuser", 1, 1, 1, 1, 1, 1, 1) + if err != nil { + t.Error("AddWeeklyReport failed:", err) + } + + // Retrieve the added report + report, err := db.GetWeeklyReport("testuser", "testproject", 1) + if err != nil { + t.Error("GetWeeklyReport failed:", err) + } + + // Print project manager's ID + projectManagerID, err := db.GetUserId("projectManager") + if err != nil { + t.Error("GetUserId failed:", err) + } + fmt.Println("Project Manager's ID:", projectManagerID) + + // Sign the report with the project manager + err = db.SignWeeklyReport(report.ReportId, projectManagerID) + if err != nil { + t.Error("SignWeeklyReport failed:", err) + } + + // Retrieve the report again to check if it's signed + signedReport, err := db.GetWeeklyReport("testuser", "testproject", 1) + if err != nil { + t.Error("GetWeeklyReport failed:", err) + } + + // Ensure the report is signed by the project manager + if *signedReport.SignedBy != projectManagerID { + t.Errorf("Expected SignedBy to be %d, got %d", projectManagerID, *signedReport.SignedBy) + } +} + +func TestSignWeeklyReportByAnotherProjectManager(t *testing.T) { + db, err := setupState() + if err != nil { + t.Error("setupState failed:", err) + } + + // Add project manager + err = db.AddUser("projectManager", "password") + if err != nil { + t.Error("AddUser failed:", err) + } + + // Add a regular user + err = db.AddUser("testuser", "password") + if err != nil { + t.Error("AddUser failed:", err) + } + + // Add project + err = db.AddProject("testproject", "description", "projectManager") + if err != nil { + t.Error("AddProject failed:", err) + } + + // Add the regular user as a member to the project + err = db.AddUserToProject("testuser", "testproject", "member") + if err != nil { + t.Error("AddUserToProject failed:", err) + } + + // Add a weekly report for the regular user + err = db.AddWeeklyReport("testproject", "testuser", 1, 1, 1, 1, 1, 1, 1) + if err != nil { + t.Error("AddWeeklyReport failed:", err) + } + + // Retrieve the added report + report, err := db.GetWeeklyReport("testuser", "testproject", 1) + if err != nil { + t.Error("GetWeeklyReport failed:", err) + } + + anotherManagerID, err := db.GetUserId("projectManager") + if err != nil { + t.Error("GetUserId failed:", err) + } + + err = db.SignWeeklyReport(report.ReportId, anotherManagerID) + if err == nil { + t.Error("Expected SignWeeklyReport to fail with a project manager who is not in the project, but it didn't") + } +} diff --git a/backend/internal/database/migrations/0010_users.sql b/backend/internal/database/migrations/0010_users.sql index 5c9d329..d2e2dd1 100644 --- a/backend/internal/database/migrations/0010_users.sql +++ b/backend/internal/database/migrations/0010_users.sql @@ -3,7 +3,7 @@ -- username is what is used for login -- password is the hashed password CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY, + id INTEGER PRIMARY KEY AUTOINCREMENT, userId TEXT DEFAULT (HEX(RANDOMBLOB(4))) NOT NULL UNIQUE, username VARCHAR(255) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL diff --git a/backend/internal/database/migrations/0020_projects.sql b/backend/internal/database/migrations/0020_projects.sql index 58d8e97..99bce86 100644 --- a/backend/internal/database/migrations/0020_projects.sql +++ b/backend/internal/database/migrations/0020_projects.sql @@ -1,5 +1,5 @@ CREATE TABLE IF NOT EXISTS projects ( - id INTEGER PRIMARY KEY, + id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255) NOT NULL UNIQUE, description TEXT NOT NULL, owner_user_id INTEGER NOT NULL, diff --git a/backend/internal/database/migrations/0030_time_reports.sql b/backend/internal/database/migrations/0030_time_reports.sql deleted file mode 100644 index 76812a1..0000000 --- a/backend/internal/database/migrations/0030_time_reports.sql +++ /dev/null @@ -1,20 +0,0 @@ -CREATE TABLE IF NOT EXISTS time_reports ( - id INTEGER PRIMARY KEY, - project_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - start DATETIME NOT NULL, - end DATETIME NOT NULL, - FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE -); - -CREATE TRIGGER IF NOT EXISTS time_reports_start_before_end - BEFORE INSERT ON time_reports - FOR EACH ROW - BEGIN - SELECT - CASE - WHEN NEW.start >= NEW.end THEN - RAISE (ABORT, 'start must be before end') - END; - END; \ No newline at end of file diff --git a/backend/internal/database/migrations/0035_weekly_report.sql b/backend/internal/database/migrations/0035_weekly_report.sql new file mode 100644 index 0000000..8f76b80 --- /dev/null +++ b/backend/internal/database/migrations/0035_weekly_report.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS weekly_reports ( + report_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + project_id INTEGER NOT NULL, + week INTEGER NOT NULL, + development_time INTEGER, + meeting_time INTEGER, + admin_time INTEGER, + own_work_time INTEGER, + study_time INTEGER, + testing_time INTEGER, + signed_by INTEGER, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (signed_by) REFERENCES users(id) +); \ No newline at end of file diff --git a/backend/internal/database/migrations/0040_time_report_collections.sql b/backend/internal/database/migrations/0040_time_report_collections.sql deleted file mode 100644 index be406ff..0000000 --- a/backend/internal/database/migrations/0040_time_report_collections.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS report_collection ( - id INTEGER PRIMARY KEY, - owner_id INTEGER NOT NULL, - project_id INTEGER NOT NULL, - date DATE NOT NULL, - signed_by INTEGER, -- NULL if not signed - FOREIGN KEY (owner_id) REFERENCES users (id) - FOREIGN KEY (signed_by) REFERENCES users (id) -); \ No newline at end of file diff --git a/backend/internal/database/migrations/0070_salts.sql b/backend/internal/database/migrations/0070_salts.sql deleted file mode 100644 index b84dfac..0000000 --- a/backend/internal/database/migrations/0070_salts.sql +++ /dev/null @@ -1,16 +0,0 @@ --- It is unclear weather this table will be used - --- Create the table to store hash salts -CREATE TABLE salts ( - id INTEGER PRIMARY KEY, - salt TEXT NOT NULL -); - --- Commented out for now, no time for good practices, which is atrocious --- Create a trigger to automatically generate a salt when inserting a new user record --- CREATE TRIGGER generate_salt_trigger --- AFTER INSERT ON users --- BEGIN --- INSERT INTO salts (salt) VALUES (randomblob(16)); --- UPDATE users SET salt_id = (SELECT last_insert_rowid()) WHERE id = new.id; --- END; diff --git a/backend/internal/handlers/global_state.go b/backend/internal/handlers/global_state.go index fea0dfd..c8beb1c 100644 --- a/backend/internal/handlers/global_state.go +++ b/backend/internal/handlers/global_state.go @@ -1,12 +1,9 @@ package handlers import ( - "time" "ttime/internal/database" - "ttime/internal/types" "github.com/gofiber/fiber/v2" - "github.com/golang-jwt/jwt/v5" ) // The actual interface that we will use @@ -17,6 +14,9 @@ type GlobalState interface { LoginRenew(c *fiber.Ctx) error // To renew the token CreateProject(c *fiber.Ctx) error // To create a new project GetUserProjects(c *fiber.Ctx) error // To get all projects + SubmitWeeklyReport(c *fiber.Ctx) error + GetWeeklyReport(c *fiber.Ctx) error + SignReport(c *fiber.Ctx) error // GetProject(c *fiber.Ctx) error // To get a specific project // UpdateProject(c *fiber.Ctx) error // To update a project // DeleteProject(c *fiber.Ctx) error // To delete a project @@ -49,45 +49,6 @@ type GState struct { ButtonCount int } -// Register is a simple handler that registers a new user -// -// @Summary Register a new user -// @Description Register a new user -// @Tags User -// @Accept json -// @Produce json -// @Success 200 {string} string "User added" -// @Failure 400 {string} string "Bad request" -// @Failure 500 {string} string "Internal server error" -// @Router /api/register [post] -func (gs *GState) Register(c *fiber.Ctx) error { - u := new(types.NewUser) - if err := c.BodyParser(u); err != nil { - return c.Status(400).SendString(err.Error()) - } - - if err := gs.Db.AddUser(u.Username, u.Password); err != nil { - return c.Status(500).SendString(err.Error()) - } - - return c.Status(200).SendString("User added") -} - -// This path should obviously be protected in the future -// UserDelete deletes a user from the database -func (gs *GState) UserDelete(c *fiber.Ctx) error { - u := new(types.User) - if err := c.BodyParser(u); err != nil { - return c.Status(400).SendString(err.Error()) - } - - if err := gs.Db.RemoveUser(u.Username); err != nil { - return c.Status(500).SendString(err.Error()) - } - - return c.Status(200).SendString("User deleted") -} - func (gs *GState) GetButtonCount(c *fiber.Ctx) error { return c.Status(200).JSON(fiber.Map{"pressCount": gs.ButtonCount}) } @@ -96,132 +57,3 @@ func (gs *GState) IncrementButtonCount(c *fiber.Ctx) error { gs.ButtonCount++ return c.Status(200).JSON(fiber.Map{"pressCount": gs.ButtonCount}) } - -// Login is a simple login handler that returns a JWT token -func (gs *GState) Login(c *fiber.Ctx) error { - // To test: curl --data "user=user&pass=pass" http://localhost:8080/api/login - user := c.FormValue("user") - pass := c.FormValue("pass") - - // Throws Unauthorized error - if user != "user" || pass != "pass" { - return c.SendStatus(fiber.StatusUnauthorized) - } - - // Create the Claims - claims := jwt.MapClaims{ - "name": user, - "admin": false, - "exp": time.Now().Add(time.Hour * 72).Unix(), - } - - // Create token - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - - // Generate encoded token and send it as response. - t, err := token.SignedString([]byte("secret")) - if err != nil { - return c.SendStatus(fiber.StatusInternalServerError) - } - - return c.JSON(fiber.Map{"token": t}) -} - -// LoginRenew is a simple handler that renews the token -func (gs *GState) LoginRenew(c *fiber.Ctx) error { - // For testing: curl localhost:3000/restricted -H "Authorization: Bearer " - user := c.Locals("user").(*jwt.Token) - claims := user.Claims.(jwt.MapClaims) - claims["exp"] = time.Now().Add(time.Hour * 72).Unix() - renewed := jwt.MapClaims{ - "name": claims["name"], - "admin": claims["admin"], - "exp": claims["exp"], - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, renewed) - t, err := token.SignedString([]byte("secret")) - if err != nil { - return c.SendStatus(fiber.StatusInternalServerError) - } - return c.JSON(fiber.Map{"token": t}) -} - -// CreateProject is a simple handler that creates a new project -func (gs *GState) CreateProject(c *fiber.Ctx) error { - user := c.Locals("user").(*jwt.Token) - - p := new(types.NewProject) - if err := c.BodyParser(p); err != nil { - return c.Status(400).SendString(err.Error()) - } - - // Get the username from the token and set it as the owner of the project - // This is ugly but - claims := user.Claims.(jwt.MapClaims) - p.Owner = claims["name"].(string) - - if err := gs.Db.AddProject(p.Name, p.Description, p.Owner); err != nil { - return c.Status(500).SendString(err.Error()) - } - - return c.Status(200).SendString("Project added") -} - -// GetUserProjects returns all projects that the user is a member of -func (gs *GState) GetUserProjects(c *fiber.Ctx) error { - // First we get the username from the token - user := c.Locals("user").(*jwt.Token) - claims := user.Claims.(jwt.MapClaims) - username := claims["name"].(string) - - // Then dip into the database to get the projects - projects, err := gs.Db.GetProjectsForUser(username) - if err != nil { - return c.Status(500).SendString(err.Error()) - } - - // Return a json serialized list of projects - return c.JSON(projects) -} - -// ListAllUsers is a handler that returns a list of all users in the application database -func (gs *GState) ListAllUsers(c *fiber.Ctx) error { - // Get all users from the database - users, err := gs.Db.GetAllUsersApplication() - if err != nil { - return c.Status(500).SendString(err.Error()) - } - - // Return the list of users as JSON - return c.JSON(users) -} - -func (gs *GState) ListAllUsersProject(c *fiber.Ctx) error { - // Extract the project name from the request parameters or body - projectName := c.Params("projectName") - - // Get all users associated with the project from the database - users, err := gs.Db.GetAllUsersProject(projectName) - if err != nil { - return c.Status(500).SendString(err.Error()) - } - - // Return the list of users as JSON - return c.JSON(users) -} - -// ProjectRoleChange is a handler that changes a user's role within a project -func (gs *GState) ProjectRoleChange(c *fiber.Ctx) error { - // Extract the necessary parameters from the request - username := c.Params("username") - projectName := c.Params("projectName") - role := c.Params("role") - - // Change the user's role within the project in the database - if err := gs.Db.ChangeUserRole(username, projectName, role); err != nil { - return c.Status(500).SendString(err.Error()) - } - - // Return a success message - return c.SendStatus(fiber.StatusOK) -} diff --git a/backend/internal/handlers/handlers_project_related.go b/backend/internal/handlers/handlers_project_related.go new file mode 100644 index 0000000..6a430e9 --- /dev/null +++ b/backend/internal/handlers/handlers_project_related.go @@ -0,0 +1,98 @@ +package handlers + +import ( + "strconv" + "ttime/internal/types" + + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v5" +) + +// CreateProject is a simple handler that creates a new project +func (gs *GState) CreateProject(c *fiber.Ctx) error { + user := c.Locals("user").(*jwt.Token) + + p := new(types.NewProject) + if err := c.BodyParser(p); err != nil { + return c.Status(400).SendString(err.Error()) + } + + // Get the username from the token and set it as the owner of the project + // This is ugly but + claims := user.Claims.(jwt.MapClaims) + owner := claims["name"].(string) + + if err := gs.Db.AddProject(p.Name, p.Description, owner); err != nil { + return c.Status(500).SendString(err.Error()) + } + + return c.Status(200).SendString("Project added") +} + +// GetUserProjects returns all projects that the user is a member of +func (gs *GState) GetUserProjects(c *fiber.Ctx) error { + // First we get the username from the token + user := c.Locals("user").(*jwt.Token) + claims := user.Claims.(jwt.MapClaims) + username := claims["name"].(string) + + // Then dip into the database to get the projects + projects, err := gs.Db.GetProjectsForUser(username) + if err != nil { + return c.Status(500).SendString(err.Error()) + } + + // Return a json serialized list of projects + return c.JSON(projects) +} + +// ProjectRoleChange is a handler that changes a user's role within a project +func (gs *GState) ProjectRoleChange(c *fiber.Ctx) error { + // Extract the necessary parameters from the request + username := c.Params("username") + projectName := c.Params("projectName") + role := c.Params("role") + + // Change the user's role within the project in the database + if err := gs.Db.ChangeUserRole(username, projectName, role); err != nil { + return c.Status(500).SendString(err.Error()) + } + + // Return a success message + return c.SendStatus(fiber.StatusOK) +} + +// GetProject retrieves a specific project by its ID +func (gs *GState) GetProject(c *fiber.Ctx) error { + // Extract the project ID from the request parameters or body + projectID := c.Params("projectID") + + // Parse the project ID into an integer + projectIDInt, err := strconv.Atoi(projectID) + if err != nil { + return c.Status(400).SendString("Invalid project ID") + } + + // Get the project from the database by its ID + project, err := gs.Db.GetProject(projectIDInt) + if err != nil { + return c.Status(500).SendString(err.Error()) + } + + // Return the project as JSON + return c.JSON(project) +} + +func (gs *GState) ListAllUsersProject(c *fiber.Ctx) error { + // Extract the project name from the request parameters or body + projectName := c.Params("projectName") + + // Get all users associated with the project from the database + users, err := gs.Db.GetAllUsersProject(projectName) + if err != nil { + return c.Status(500).SendString(err.Error()) + } + + // Return the list of users as JSON + return c.JSON(users) +} diff --git a/backend/internal/handlers/handlers_report_related.go b/backend/internal/handlers/handlers_report_related.go new file mode 100644 index 0000000..509bd67 --- /dev/null +++ b/backend/internal/handlers/handlers_report_related.go @@ -0,0 +1,93 @@ +package handlers + +import ( + "strconv" + "ttime/internal/types" + + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v5" +) + +func (gs *GState) SubmitWeeklyReport(c *fiber.Ctx) error { + // Extract the necessary parameters from the token + user := c.Locals("user").(*jwt.Token) + claims := user.Claims.(jwt.MapClaims) + username := claims["name"].(string) + + report := new(types.NewWeeklyReport) + if err := c.BodyParser(report); err != nil { + return c.Status(400).SendString(err.Error()) + } + + // Make sure all the fields of the report are valid + if report.Week < 1 || report.Week > 52 { + return c.Status(400).SendString("Invalid week number") + } + if report.DevelopmentTime < 0 || report.MeetingTime < 0 || report.AdminTime < 0 || report.OwnWorkTime < 0 || report.StudyTime < 0 || report.TestingTime < 0 { + return c.Status(400).SendString("Invalid time report") + } + + if err := gs.Db.AddWeeklyReport(report.ProjectName, username, report.Week, report.DevelopmentTime, report.MeetingTime, report.AdminTime, report.OwnWorkTime, report.StudyTime, report.TestingTime); err != nil { + return c.Status(500).SendString(err.Error()) + } + + return c.Status(200).SendString("Time report added") +} + +// Handler for retrieving weekly report +func (gs *GState) GetWeeklyReport(c *fiber.Ctx) error { + // Extract the necessary parameters from the request + println("GetWeeklyReport") + user := c.Locals("user").(*jwt.Token) + claims := user.Claims.(jwt.MapClaims) + username := claims["name"].(string) + + // Extract project name and week from query parameters + projectName := c.Query("projectName") + println(projectName) + week := c.Query("week") + println(week) + + // Convert week to integer + weekInt, err := strconv.Atoi(week) + if err != nil { + return c.Status(400).SendString("Invalid week number") + } + + // Call the database function to get the weekly report + report, err := gs.Db.GetWeeklyReport(username, projectName, weekInt) + if err != nil { + return c.Status(500).SendString(err.Error()) + } + + // Return the retrieved weekly report + return c.JSON(report) +} + +func (gs *GState) SignReport(c *fiber.Ctx) error { + // Extract the necessary parameters from the token + user := c.Locals("user").(*jwt.Token) + claims := user.Claims.(jwt.MapClaims) + managerUsername := claims["name"].(string) + + // Extract the report ID and project manager ID from request parameters + reportID, err := strconv.Atoi(c.Params("reportId")) + if err != nil { + return c.Status(400).SendString("Invalid report ID") + } + + // Call the database function to get the project manager ID + managerID, err := gs.Db.GetUserId(managerUsername) + if err != nil { + return c.Status(500).SendString("Failed to get project manager ID") + } + + // Call the database function to sign the weekly report + err = gs.Db.SignWeeklyReport(reportID, managerID) + if err != nil { + return c.Status(500).SendString("Failed to sign the weekly report: " + err.Error()) + } + + // Return success response + return c.Status(200).SendString("Weekly report signed successfully") +} diff --git a/backend/internal/handlers/handlers_user_related.go b/backend/internal/handlers/handlers_user_related.go new file mode 100644 index 0000000..0619ea5 --- /dev/null +++ b/backend/internal/handlers/handlers_user_related.go @@ -0,0 +1,124 @@ +package handlers + +import ( + "time" + "ttime/internal/types" + + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v5" +) + +// Register is a simple handler that registers a new user +// +// @Summary Register a new user +// @Description Register a new user +// @Tags User +// @Accept json +// @Produce json +// @Success 200 {string} string "User added" +// @Failure 400 {string} string "Bad request" +// @Failure 500 {string} string "Internal server error" +// @Router /api/register [post] +func (gs *GState) Register(c *fiber.Ctx) error { + u := new(types.NewUser) + if err := c.BodyParser(u); err != nil { + println("Error parsing body") + return c.Status(400).SendString(err.Error()) + } + + println("Adding user:", u.Username) + if err := gs.Db.AddUser(u.Username, u.Password); err != nil { + return c.Status(500).SendString(err.Error()) + } + + println("User added:", u.Username) + return c.Status(200).SendString("User added") +} + +// This path should obviously be protected in the future +// UserDelete deletes a user from the database +func (gs *GState) UserDelete(c *fiber.Ctx) error { + // Read from path parameters + username := c.Params("username") + + // Read username from Locals + auth_username := c.Locals("user").(*jwt.Token).Claims.(jwt.MapClaims)["name"].(string) + + if username != auth_username { + return c.Status(403).SendString("You can only delete yourself") + } + + if err := gs.Db.RemoveUser(username); err != nil { + return c.Status(500).SendString(err.Error()) + } + + return c.Status(200).SendString("User deleted") +} + +// Login is a simple login handler that returns a JWT token +func (gs *GState) Login(c *fiber.Ctx) error { + // The body type is identical to a NewUser + u := new(types.NewUser) + if err := c.BodyParser(u); err != nil { + println("Error parsing body") + return c.Status(400).SendString(err.Error()) + } + + println("Username:", u.Username) + if !gs.Db.CheckUser(u.Username, u.Password) { + println("User not found") + return c.SendStatus(fiber.StatusUnauthorized) + } + + // Create the Claims + claims := jwt.MapClaims{ + "name": u.Username, + "admin": false, + "exp": time.Now().Add(time.Hour * 72).Unix(), + } + + // Create token + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + println("Token created for user:", u.Username) + + // Generate encoded token and send it as response. + t, err := token.SignedString([]byte("secret")) + if err != nil { + println("Error signing token") + return c.SendStatus(fiber.StatusInternalServerError) + } + + println("Successfully signed token for user:", u.Username) + return c.JSON(fiber.Map{"token": t}) +} + +// LoginRenew is a simple handler that renews the token +func (gs *GState) LoginRenew(c *fiber.Ctx) error { + // For testing: curl localhost:3000/restricted -H "Authorization: Bearer " + user := c.Locals("user").(*jwt.Token) + claims := user.Claims.(jwt.MapClaims) + claims["exp"] = time.Now().Add(time.Hour * 72).Unix() + renewed := jwt.MapClaims{ + "name": claims["name"], + "admin": claims["admin"], + "exp": claims["exp"], + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, renewed) + t, err := token.SignedString([]byte("secret")) + if err != nil { + return c.SendStatus(fiber.StatusInternalServerError) + } + return c.JSON(fiber.Map{"token": t}) +} + +// ListAllUsers is a handler that returns a list of all users in the application database +func (gs *GState) ListAllUsers(c *fiber.Ctx) error { + // Get all users from the database + users, err := gs.Db.GetAllUsersApplication() + if err != nil { + return c.Status(500).SendString(err.Error()) + } + + // Return the list of users as JSON + return c.JSON(users) +} diff --git a/backend/internal/types/WeeklyReport.go b/backend/internal/types/WeeklyReport.go new file mode 100644 index 0000000..299395a --- /dev/null +++ b/backend/internal/types/WeeklyReport.go @@ -0,0 +1,46 @@ +package types + +// This is what should be submitted to the server, the username will be derived from the JWT token +type NewWeeklyReport struct { + // The name of the project, as it appears in the database + ProjectName string `json:"projectName"` + // The week number + Week int `json:"week"` + // Total time spent on development + DevelopmentTime int `json:"developmentTime"` + // Total time spent in meetings + MeetingTime int `json:"meetingTime"` + // Total time spent on administrative tasks + AdminTime int `json:"adminTime"` + // Total time spent on personal projects + OwnWorkTime int `json:"ownWorkTime"` + // Total time spent on studying + StudyTime int `json:"studyTime"` + // Total time spent on testing + TestingTime int `json:"testingTime"` +} + +type WeeklyReport struct { + // The ID of the report + ReportId int `json:"reportId" db:"report_id"` + // The user id of the user who submitted the report + UserId int `json:"userId" db:"user_id"` + // The name of the project, as it appears in the database + ProjectId int `json:"projectId" db:"project_id"` + // The week number + Week int `json:"week" db:"week"` + // Total time spent on development + DevelopmentTime int `json:"developmentTime" db:"development_time"` + // Total time spent in meetings + MeetingTime int `json:"meetingTime" db:"meeting_time"` + // Total time spent on administrative tasks + AdminTime int `json:"adminTime" db:"admin_time"` + // Total time spent on personal projects + OwnWorkTime int `json:"ownWorkTime" db:"own_work_time"` + // Total time spent on studying + StudyTime int `json:"studyTime" db:"study_time"` + // Total time spent on testing + TestingTime int `json:"testingTime" db:"testing_time"` + // The project manager who signed it + SignedBy *int `json:"signedBy" db:"signed_by"` +} diff --git a/backend/internal/types/project.go b/backend/internal/types/project.go index 8fcfaf5..7e1747f 100644 --- a/backend/internal/types/project.go +++ b/backend/internal/types/project.go @@ -8,9 +8,8 @@ type Project struct { Owner string `json:"owner" db:"owner_user_id"` } -// As it arrives from the client +// As it arrives from the client, Owner is derived from the JWT token type NewProject struct { Name string `json:"name"` Description string `json:"description"` - Owner string `json:"owner"` } diff --git a/backend/internal/types/users.go b/backend/internal/types/users.go index 233ec71..e9dff67 100644 --- a/backend/internal/types/users.go +++ b/backend/internal/types/users.go @@ -16,6 +16,7 @@ func (u *User) ToPublicUser() (*PublicUser, error) { }, nil } +// Should be used when registering, for example type NewUser struct { Username string `json:"username"` Password string `json:"password"` diff --git a/backend/main.go b/backend/main.go index 4e0935c..bc33942 100644 --- a/backend/main.go +++ b/backend/main.go @@ -43,6 +43,11 @@ func main() { // Connect to the database db := database.DbConnect(conf.DbPath) + // Migrate the database + if err = db.Migrate(); err != nil { + fmt.Println("Error migrating database: ", err) + } + // Get our global state gs := handlers.NewGlobalState(db) // Create the server @@ -68,10 +73,12 @@ func main() { SigningKey: jwtware.SigningKey{Key: []byte("secret")}, })) + server.Post("/api/submitReport", gs.SubmitWeeklyReport) server.Get("/api/getUserProjects", gs.GetUserProjects) server.Post("/api/loginrenew", gs.LoginRenew) - server.Delete("/api/userdelete", gs.UserDelete) // Perhaps just use POST to avoid headaches + server.Delete("/api/userdelete/:username", gs.UserDelete) // Perhaps just use POST to avoid headaches server.Post("/api/project", gs.CreateProject) + server.Get("/api/getWeeklyReport", gs.GetWeeklyReport) // Announce the port we are listening on and start the server err = server.Listen(fmt.Sprintf(":%d", conf.Port)) diff --git a/backend/tygo.yaml b/backend/tygo.yaml new file mode 100644 index 0000000..54c1e8f --- /dev/null +++ b/backend/tygo.yaml @@ -0,0 +1,9 @@ +packages: + - path: "ttime/internal/types" + output_path: "../frontend/src/Types/goTypes.ts" + type_mappings: + time.Time: "string /* RFC3339 */" + null.String: "null | string" + null.Bool: "null | boolean" + uuid.UUID: "string /* uuid */" + uuid.NullUUID: "null | string /* uuid */" diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 447a464..1051c03 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -9,7 +9,7 @@ module.exports = { 'plugin:react-hooks/recommended', 'plugin:prettier/recommended', ], - ignorePatterns: ['dist', '.eslintrc.cjs', 'tailwind.config.js', 'postcss.config.js', 'jest.config.cjs'], + ignorePatterns: ['dist', '.eslintrc.cjs', 'tailwind.config.js', 'postcss.config.js', 'jest.config.cjs', 'goTypes.ts'], parser: '@typescript-eslint/parser', plugins: ['react-refresh', 'prettier'], rules: { diff --git a/frontend/src/API/API.ts b/frontend/src/API/API.ts index f33c87c..7a1ccd0 100644 --- a/frontend/src/API/API.ts +++ b/frontend/src/API/API.ts @@ -1,57 +1,256 @@ -import { NewProject, Project } from "../Types/Project"; -import { NewUser, User } from "../Types/Users"; +import { + NewWeeklyReport, + NewUser, + User, + Project, + NewProject, +} from "../Types/goTypes"; +// This type of pattern should be hard to misuse +export interface APIResponse { + success: boolean; + message?: string; + data?: T; +} + +// Note that all protected routes also require a token // Defines all the methods that an instance of the API must implement interface API { /** Register a new user */ - registerUser(user: NewUser): Promise; + registerUser(user: NewUser): Promise>; /** Remove a user */ - removeUser(username: string): Promise; - /** Create a project */ - createProject(project: NewProject): Promise; + removeUser(username: string, token: string): Promise>; + /** Login */ + login(NewUser: NewUser): Promise>; /** Renew the token */ - renewToken(token: string): Promise; + renewToken(token: string): Promise>; + /** Create a project */ + createProject( + project: NewProject, + token: string, + ): Promise>; + /** Gets all the projects of a user*/ + getUserProjects( + username: string, + token: string, + ): Promise>; + /** Submit a weekly report */ + submitWeeklyReport( + project: NewWeeklyReport, + token: string, + ): Promise>; + /**Gets a weekly report*/ + getWeeklyReport( + username: string, + projectName: string, + week: string, + token: string, + ): Promise>; } // Export an instance of the API export const api: API = { - async registerUser(user: NewUser): Promise { - return fetch("/api/register", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(user), - }).then((res) => res.json() as Promise); + async registerUser(user: NewUser): Promise> { + try { + const response = await fetch("/api/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(user), + }); + + if (!response.ok) { + return { + success: false, + message: "Failed to register user: " + response.status, + }; + } else { + // const data = (await response.json()) as User; // The API does not currently return the user + return { success: true }; + } + } catch (e) { + return { + success: false, + message: "Unknown error while registering user", + }; + } }, - async removeUser(username: string): Promise { - return fetch("/api/userdelete", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(username), - }).then((res) => res.json() as Promise); + async removeUser( + username: string, + token: string, + ): Promise> { + try { + const response = await fetch("/api/userdelete", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + token, + }, + body: JSON.stringify(username), + }); + + if (!response.ok) { + return { success: false, message: "Failed to remove user" }; + } else { + const data = (await response.json()) as User; + return { success: true, data }; + } + } catch (e) { + return { success: false, message: "Failed to remove user" }; + } }, - async createProject(project: NewProject): Promise { - return fetch("/api/project", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(project), - }).then((res) => res.json() as Promise); + async createProject( + project: NewProject, + token: string, + ): Promise> { + try { + const response = await fetch("/api/project", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + token, + }, + body: JSON.stringify(project), + }); + + if (!response.ok) { + return { success: false, message: "Failed to create project" }; + } else { + const data = (await response.json()) as Project; + return { success: true, data }; + } + } catch (e) { + return { success: false, message: "Failed to create project" }; + } }, - async renewToken(token: string): Promise { - return fetch("/api/loginrenew", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer " + token, - }, - }).then((res) => res.json() as Promise); + async renewToken(token: string): Promise> { + try { + const response = await fetch("/api/loginrenew", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + token, + }, + }); + + if (!response.ok) { + return { success: false, message: "Failed to renew token" }; + } else { + const data = (await response.json()) as string; + return { success: true, data }; + } + } catch (e) { + return { success: false, message: "Failed to renew token" }; + } + }, + + async getUserProjects(token: string): Promise> { + try { + const response = await fetch("/api/getUserProjects", { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + token, + }, + }); + + if (!response.ok) { + return Promise.resolve({ + success: false, + message: "Failed to get user projects", + }); + } else { + const data = (await response.json()) as Project[]; + return Promise.resolve({ success: true, data }); + } + } catch (e) { + return Promise.resolve({ + success: false, + message: "Failed to get user projects", + }); + } + }, + + async submitWeeklyReport( + weeklyReport: NewWeeklyReport, + token: string, + ): Promise> { + try { + const response = await fetch("/api/submitWeeklyReport", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + token, + }, + body: JSON.stringify(weeklyReport), + }); + + if (!response.ok) { + return { + success: false, + message: "Failed to submit weekly report", + }; + } + + const data = (await response.json()) as NewWeeklyReport; + return { success: true, data }; + } catch (e) { + return { + success: false, + message: "Failed to submit weekly report", + }; + } + }, + + async getWeeklyReport( + username: string, + projectName: string, + week: string, + token: string, + ): Promise> { + try { + const response = await fetch("/api/getWeeklyReport", { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + token, + }, + body: JSON.stringify({ username, projectName, week }), + }); + + if (!response.ok) { + return { success: false, message: "Failed to get weekly report" }; + } else { + const data = (await response.json()) as NewWeeklyReport; + return { success: true, data }; + } + } catch (e) { + return { success: false, message: "Failed to get weekly report" }; + } + }, + + async login(NewUser: NewUser): Promise> { + try { + const response = await fetch("/api/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(NewUser), + }); + + if (!response.ok) { + return { success: false, message: "Failed to login" }; + } else { + const data = (await response.json()) as { token: string }; // Update the type of 'data' + return { success: true, data: data.token }; + } + } catch (e) { + return Promise.resolve({ success: false, message: "Failed to login" }); + } }, }; diff --git a/frontend/src/Components/AddProject.tsx b/frontend/src/Components/AddProject.tsx new file mode 100644 index 0000000..45814e3 --- /dev/null +++ b/frontend/src/Components/AddProject.tsx @@ -0,0 +1,94 @@ +import { useState } from "react"; +import { APIResponse, api } from "../API/API"; +import { NewProject, Project } from "../Types/goTypes"; +import InputField from "./InputField"; +import Logo from "../assets/Logo.svg"; +import Button from "./Button"; + +/** + * Tries to add a project to the system + * @param props - Project name and description + * @returns {boolean} True if created, false if not + */ +function CreateProject(props: { name: string; description: string }): boolean { + const project: NewProject = { + name: props.name, + description: props.description, + }; + + let created = false; + + api + .createProject(project, localStorage.getItem("accessToken") ?? "") + .then((response: APIResponse) => { + if (response.success) { + created = true; + } else { + console.error(response.message); + } + }) + .catch((error) => { + console.error("An error occurred during creation:", error); + }); + return created; +} + +/** + * Tries to add a project to the system + * @returns {JSX.Element} UI for project adding + */ +function AddProject(): JSX.Element { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + + return ( +
+
+
{ + e.preventDefault(); + CreateProject({ name: name, description: description }); + }} + > + TTIME Logo +

+ Create a new project +

+ { + setName(e.target.value); + }} + /> + { + setDescription(e.target.value); + }} + /> +
+
+ +

+
+
+ ); +} + +export default AddProject; diff --git a/frontend/src/Components/BackgroundAnimation.tsx b/frontend/src/Components/BackgroundAnimation.tsx new file mode 100644 index 0000000..5f402c0 --- /dev/null +++ b/frontend/src/Components/BackgroundAnimation.tsx @@ -0,0 +1,24 @@ +import { useEffect } from "react"; + +const BackgroundAnimation = (): JSX.Element => { + useEffect(() => { + const images = [ + "src/assets/1.jpg", + "src/assets/2.jpg", + "src/assets/3.jpg", + "src/assets/4.jpg", + ]; + + // Pre-load images + for (const i of images) { + console.log(i); + } + + // Start animation + document.body.style.animation = "backgroundTransition 30s infinite"; + }, []); + + return <>; +}; + +export default BackgroundAnimation; diff --git a/frontend/src/Components/BasicWindow.tsx b/frontend/src/Components/BasicWindow.tsx index 1835d6a..d5fd3b6 100644 --- a/frontend/src/Components/BasicWindow.tsx +++ b/frontend/src/Components/BasicWindow.tsx @@ -2,17 +2,15 @@ import Header from "./Header"; import Footer from "./Footer"; function BasicWindow({ - username, content, buttons, }: { - username: string; content: React.ReactNode; buttons: React.ReactNode; }): JSX.Element { return (
-
+
{content}
{buttons}
diff --git a/frontend/src/Components/Button.tsx b/frontend/src/Components/Button.tsx index cf6a887..38a1853 100644 --- a/frontend/src/Components/Button.tsx +++ b/frontend/src/Components/Button.tsx @@ -1,14 +1,17 @@ function Button({ text, onClick, + type, }: { text: string; onClick: () => void; + type: "submit" | "button" | "reset"; }): JSX.Element { return ( diff --git a/frontend/src/Components/EditWeeklyReport.tsx b/frontend/src/Components/EditWeeklyReport.tsx new file mode 100644 index 0000000..9321d73 --- /dev/null +++ b/frontend/src/Components/EditWeeklyReport.tsx @@ -0,0 +1,247 @@ +import { useState, useEffect } from "react"; +import { NewWeeklyReport } from "../Types/goTypes"; +import { api } from "../API/API"; +import { useNavigate } from "react-router-dom"; +import Button from "./Button"; + +export default function GetWeeklyReport(): JSX.Element { + const [projectName, setProjectName] = useState(""); + const [week, setWeek] = useState(0); + const [developmentTime, setDevelopmentTime] = useState(0); + const [meetingTime, setMeetingTime] = useState(0); + const [adminTime, setAdminTime] = useState(0); + const [ownWorkTime, setOwnWorkTime] = useState(0); + const [studyTime, setStudyTime] = useState(0); + const [testingTime, setTestingTime] = useState(0); + + const token = localStorage.getItem("accessToken") ?? ""; + const username = localStorage.getItem("username") ?? ""; + + useEffect(() => { + const fetchWeeklyReport = async (): Promise => { + const response = await api.getWeeklyReport( + username, + projectName, + week.toString(), + token, + ); + + if (response.success) { + const report: NewWeeklyReport = response.data ?? { + projectName: "", + week: 0, + developmentTime: 0, + meetingTime: 0, + adminTime: 0, + ownWorkTime: 0, + studyTime: 0, + testingTime: 0, + }; + setProjectName(report.projectName); + setWeek(report.week); + setDevelopmentTime(report.developmentTime); + setMeetingTime(report.meetingTime); + setAdminTime(report.adminTime); + setOwnWorkTime(report.ownWorkTime); + setStudyTime(report.studyTime); + setTestingTime(report.testingTime); + } else { + console.error("Failed to fetch weekly report:", response.message); + } + }; + + fetchWeeklyReport(); + }, []); + + const handleNewWeeklyReport = async (): Promise => { + const newWeeklyReport: NewWeeklyReport = { + projectName, + week, + developmentTime, + meetingTime, + adminTime, + ownWorkTime, + studyTime, + testingTime, + }; + + await api.submitWeeklyReport(newWeeklyReport, token); + }; + + const navigate = useNavigate(); + + return ( + <> +
+
{ + if (week === 0) { + alert("Please enter a week number"); + e.preventDefault(); + return; + } + e.preventDefault(); + void handleNewWeeklyReport(); + navigate("/project"); + }} + > +
+ { + const weekNumber = parseInt(e.target.value.split("-W")[1]); + setWeek(weekNumber); + }} + onKeyDown={(event) => { + event.preventDefault(); + }} + onPaste={(event) => { + event.preventDefault(); + }} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Activity + + Total Time (min) +
Development + { + setDevelopmentTime(parseInt(e.target.value)); + }} + onKeyDown={(event) => { + const keyValue = event.key; + if (!/\d/.test(keyValue) && keyValue !== "Backspace") + event.preventDefault(); + }} + /> +
Meeting + { + setMeetingTime(parseInt(e.target.value)); + }} + onKeyDown={(event) => { + const keyValue = event.key; + if (!/\d/.test(keyValue) && keyValue !== "Backspace") + event.preventDefault(); + }} + /> +
Administration + { + setAdminTime(parseInt(e.target.value)); + }} + onKeyDown={(event) => { + const keyValue = event.key; + if (!/\d/.test(keyValue) && keyValue !== "Backspace") + event.preventDefault(); + }} + /> +
Own Work + { + setOwnWorkTime(parseInt(e.target.value)); + }} + onKeyDown={(event) => { + const keyValue = event.key; + if (!/\d/.test(keyValue) && keyValue !== "Backspace") + event.preventDefault(); + }} + /> +
Studies + { + setStudyTime(parseInt(e.target.value)); + }} + onKeyDown={(event) => { + const keyValue = event.key; + if (!/\d/.test(keyValue) && keyValue !== "Backspace") + event.preventDefault(); + }} + /> +
Testing + { + setTestingTime(parseInt(e.target.value)); + }} + onKeyDown={(event) => { + const keyValue = event.key; + if (!/\d/.test(keyValue) && keyValue !== "Backspace") + event.preventDefault(); + }} + /> +
+
+
+
+ + ); +} diff --git a/frontend/src/Components/Header.tsx b/frontend/src/Components/Header.tsx index ba0a939..819c5de 100644 --- a/frontend/src/Components/Header.tsx +++ b/frontend/src/Components/Header.tsx @@ -1,11 +1,11 @@ import { useState } from "react"; import { Link } from "react-router-dom"; -function Header({ username }: { username: string }): JSX.Element { +function Header(): JSX.Element { const [isOpen, setIsOpen] = useState(false); const handleLogout = (): void => { - // Add any logout logic here + localStorage.clear(); }; return ( @@ -31,7 +31,7 @@ function Header({ username }: { username: string }): JSX.Element { }} > {isOpen && ( diff --git a/frontend/src/Components/InputField.tsx b/frontend/src/Components/InputField.tsx new file mode 100644 index 0000000..639b4ca --- /dev/null +++ b/frontend/src/Components/InputField.tsx @@ -0,0 +1,41 @@ +/** + * A customizable input field + * @param props - Settings for the field + * @returns {JSX.Element} The input field + * @example + * { + * setExample(e.target.value); + * }} + * value={example} + * /> + */ +function InputField(props: { + label: string; + type: string; + value: string; + onChange: (e: React.ChangeEvent) => void; +}): JSX.Element { + return ( +
+ + +
+ ); +} + +export default InputField; diff --git a/frontend/src/Components/LoginCheck.tsx b/frontend/src/Components/LoginCheck.tsx new file mode 100644 index 0000000..ce7d52c --- /dev/null +++ b/frontend/src/Components/LoginCheck.tsx @@ -0,0 +1,58 @@ +import { NewUser } from "../Types/goTypes"; +import { api, APIResponse } from "../API/API"; +import { Dispatch, SetStateAction } from "react"; + +/* + * Checks if user is in database with api.login and then sets proper authority level + * TODO: change so that it checks for user type (admin, user, pm) somehow instead + **/ +function LoginCheck(props: { + username: string; + password: string; + setAuthority: Dispatch>; +}): void { + const user: NewUser = { + username: props.username, + password: props.password, + }; + + localStorage.clear(); + + api + .login(user) + .then((response: APIResponse) => { + if (response.success) { + if (response.data !== undefined) { + const token = response.data; + localStorage.setItem("accessToken", token); + localStorage.setItem("username", props.username); + //TODO: change so that it checks for user type (admin, user, pm) instead + if (token !== "" && props.username === "admin") { + props.setAuthority((prevAuth) => { + prevAuth = 1; + return prevAuth; + }); + } else if (token !== "" && props.username === "pm") { + props.setAuthority((prevAuth) => { + prevAuth = 2; + return prevAuth; + }); + } else if (token !== "" && props.username === "user") { + props.setAuthority((prevAuth) => { + prevAuth = 3; + return prevAuth; + }); + } + } else { + console.error("Token was undefined"); + } + } else { + console.error("Token could not be fetched/No such user"); + } + }) + .catch((error) => { + console.error("An error occurred during login:", error); + }); +} + +export default LoginCheck; diff --git a/frontend/src/Components/LoginField.tsx b/frontend/src/Components/LoginField.tsx new file mode 100644 index 0000000..d7768c0 --- /dev/null +++ b/frontend/src/Components/LoginField.tsx @@ -0,0 +1,55 @@ +import { Dispatch, FormEventHandler, SetStateAction } from "react"; +import Button from "./Button"; +import InputField from "./InputField"; + +/** + * A login field complete with input fields + * and a button for submitting the information + * @param props - Settings + * @returns {JSX.Element} A login component + * @example + * + */ +function Login(props: { + handleSubmit: FormEventHandler; + setUsername: Dispatch>; + setPassword: Dispatch>; + username: string; + password: string; +}): JSX.Element { + return ( +
+ { + props.setUsername(e.target.value); + }} + value={props.username} + /> + { + props.setPassword(e.target.value); + }} + value={props.password} + /> + + />

diff --git a/frontend/src/Components/TimeReport.tsx b/frontend/src/Components/TimeReport.tsx deleted file mode 100644 index c4ddc38..0000000 --- a/frontend/src/Components/TimeReport.tsx +++ /dev/null @@ -1,59 +0,0 @@ -function NewTimeReport(): JSX.Element { - const activities = [ - "Development", - "Meeting", - "Administration", - "Own Work", - "Studies", - "Testing", - ]; - - return ( - <> -
- { - event.preventDefault(); - }} - onPaste={(event) => { - event.preventDefault(); - }} - /> - - - - - - - - - {activities.map((activity, index) => ( - - - - - ))} - -
Activity - Total Time (min) -
{activity} - { - const keyValue = event.key; - if (!/\d/.test(keyValue) && keyValue !== "Backspace") - event.preventDefault(); - }} - /> -
-
- - ); -} - -export default NewTimeReport; diff --git a/frontend/src/Components/UserListAdmin.tsx b/frontend/src/Components/UserListAdmin.tsx index 42fb094..b86076a 100644 --- a/frontend/src/Components/UserListAdmin.tsx +++ b/frontend/src/Components/UserListAdmin.tsx @@ -1,11 +1,11 @@ import { Link } from "react-router-dom"; -import { User } from "../Types/Users"; +import { PublicUser } from "../Types/goTypes"; /** * The props for the UserProps component */ interface UserProps { - users: User[]; + users: PublicUser[]; } /** @@ -23,9 +23,9 @@ export function UserListAdmin(props: UserProps): JSX.Element {
    {props.users.map((user) => ( - -
  • - {user.userName} + +
  • + {user.username}
  • ))} diff --git a/frontend/src/Components/UserProjectListAdmin.tsx b/frontend/src/Components/UserProjectListAdmin.tsx new file mode 100644 index 0000000..423e793 --- /dev/null +++ b/frontend/src/Components/UserProjectListAdmin.tsx @@ -0,0 +1,43 @@ +import React, { useEffect, useState } from "react"; +import { api } from "../API/API"; +import { Project } from "../Types/goTypes"; + +const UserProjectListAdmin: React.FC = () => { + const [projects, setProjects] = useState([]); + + useEffect(() => { + const fetchProjects = async (): Promise => { + try { + const token = localStorage.getItem("accessToken") ?? ""; + const username = getUsernameFromContext(); // Assuming you have a function to get the username from your context + + const response = await api.getUserProjects(username, token); + if (response.success) { + setProjects(response.data ?? []); + } else { + console.error("Failed to fetch projects:", response.message); + } + } catch (error) { + console.error("Error fetching projects:", error); + } + }; + + void fetchProjects(); + }, []); + + return ( +
    +

    User Projects

    +
      + {projects.map((project) => ( +
    • + {project.name} + {/* Add any additional project details you want to display */} +
    • + ))} +
    +
    + ); +}; + +export default UserProjectListAdmin; diff --git a/frontend/src/Pages/AdminPages/AdminAddProject.tsx b/frontend/src/Pages/AdminPages/AdminAddProject.tsx index 9fd8bed..aedbd3f 100644 --- a/frontend/src/Pages/AdminPages/AdminAddProject.tsx +++ b/frontend/src/Pages/AdminPages/AdminAddProject.tsx @@ -1,26 +1,16 @@ +import AddProject from "../../Components/AddProject"; +import BackButton from "../../Components/BackButton"; import BasicWindow from "../../Components/BasicWindow"; -import Button from "../../Components/Button"; function AdminAddProject(): JSX.Element { - const content = <>; + const content = ; const buttons = ( <> -
diff --git a/frontend/src/Pages/ProjectManagerPages/PMChangeRole.tsx b/frontend/src/Pages/ProjectManagerPages/PMChangeRole.tsx index 8f7bf9b..9f233a1 100644 --- a/frontend/src/Pages/ProjectManagerPages/PMChangeRole.tsx +++ b/frontend/src/Pages/ProjectManagerPages/PMChangeRole.tsx @@ -12,11 +12,12 @@ function ChangeRole(): JSX.Element { onClick={(): void => { return; }} + type="button" /> ); - return ; + return ; } export default ChangeRole; diff --git a/frontend/src/Pages/ProjectManagerPages/PMOtherUsersTR.tsx b/frontend/src/Pages/ProjectManagerPages/PMOtherUsersTR.tsx index 19ebbd9..c76947b 100644 --- a/frontend/src/Pages/ProjectManagerPages/PMOtherUsersTR.tsx +++ b/frontend/src/Pages/ProjectManagerPages/PMOtherUsersTR.tsx @@ -10,6 +10,6 @@ function PMOtherUsersTR(): JSX.Element { ); - return ; + return ; } export default PMOtherUsersTR; diff --git a/frontend/src/Pages/ProjectManagerPages/PMProjectMembers.tsx b/frontend/src/Pages/ProjectManagerPages/PMProjectMembers.tsx index 2af1145..9fe96cf 100644 --- a/frontend/src/Pages/ProjectManagerPages/PMProjectMembers.tsx +++ b/frontend/src/Pages/ProjectManagerPages/PMProjectMembers.tsx @@ -14,6 +14,7 @@ function PMProjectMembers(): JSX.Element { onClick={(): void => { return; }} + type={"button"} /> @@ -22,12 +23,13 @@ function PMProjectMembers(): JSX.Element { onClick={(): void => { return; }} + type={"button"} /> ); - return ; + return ; } export default PMProjectMembers; diff --git a/frontend/src/Pages/ProjectManagerPages/PMProjectPage.tsx b/frontend/src/Pages/ProjectManagerPages/PMProjectPage.tsx index 8e724f3..bd4e6ef 100644 --- a/frontend/src/Pages/ProjectManagerPages/PMProjectPage.tsx +++ b/frontend/src/Pages/ProjectManagerPages/PMProjectPage.tsx @@ -31,6 +31,6 @@ function PMProjectPage(): JSX.Element { ); - return ; + return ; } export default PMProjectPage; diff --git a/frontend/src/Pages/ProjectManagerPages/PMTotalTimeActivity.tsx b/frontend/src/Pages/ProjectManagerPages/PMTotalTimeActivity.tsx index dd84fd7..676ea28 100644 --- a/frontend/src/Pages/ProjectManagerPages/PMTotalTimeActivity.tsx +++ b/frontend/src/Pages/ProjectManagerPages/PMTotalTimeActivity.tsx @@ -1,6 +1,6 @@ -import BasicWindow from "../../Components/BasicWindow"; -import TimeReport from "../../Components/TimeReport"; import BackButton from "../../Components/BackButton"; +import BasicWindow from "../../Components/BasicWindow"; +import TimeReport from "../../Components/NewWeeklyReport"; function PMTotalTimeActivity(): JSX.Element { const content = ( @@ -18,6 +18,6 @@ function PMTotalTimeActivity(): JSX.Element { ); - return ; + return ; } export default PMTotalTimeActivity; diff --git a/frontend/src/Pages/ProjectManagerPages/PMTotalTimeRole.tsx b/frontend/src/Pages/ProjectManagerPages/PMTotalTimeRole.tsx index 05d902f..c0161f8 100644 --- a/frontend/src/Pages/ProjectManagerPages/PMTotalTimeRole.tsx +++ b/frontend/src/Pages/ProjectManagerPages/PMTotalTimeRole.tsx @@ -10,6 +10,6 @@ function PMTotalTimeRole(): JSX.Element { ); - return ; + return ; } export default PMTotalTimeRole; diff --git a/frontend/src/Pages/ProjectManagerPages/PMUnsignedReports.tsx b/frontend/src/Pages/ProjectManagerPages/PMUnsignedReports.tsx index fae3842..713efec 100644 --- a/frontend/src/Pages/ProjectManagerPages/PMUnsignedReports.tsx +++ b/frontend/src/Pages/ProjectManagerPages/PMUnsignedReports.tsx @@ -10,6 +10,6 @@ function PMUnsignedReports(): JSX.Element { ); - return ; + return ; } export default PMUnsignedReports; diff --git a/frontend/src/Pages/ProjectManagerPages/PMViewUnsignedReport.tsx b/frontend/src/Pages/ProjectManagerPages/PMViewUnsignedReport.tsx index 3bbf3ee..aea25fb 100644 --- a/frontend/src/Pages/ProjectManagerPages/PMViewUnsignedReport.tsx +++ b/frontend/src/Pages/ProjectManagerPages/PMViewUnsignedReport.tsx @@ -1,7 +1,7 @@ +import BackButton from "../../Components/BackButton"; import BasicWindow from "../../Components/BasicWindow"; import Button from "../../Components/Button"; -import TimeReport from "../../Components/TimeReport"; -import BackButton from "../../Components/BackButton"; +import TimeReport from "../../Components/NewWeeklyReport"; function PMViewUnsignedReport(): JSX.Element { const content = ( @@ -20,17 +20,19 @@ function PMViewUnsignedReport(): JSX.Element { onClick={(): void => { return; }} + type="button" />