diff --git a/.gitignore b/.gitignore index 2d89407..bdbfff8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,13 @@ *.dylib bin +database.txt +plantuml.jar db.sqlite3 -*.png +diagram.puml +backend/*.png +backend/*.jpg +backend/*.svg # Test binary, built with `go test -c` *.test diff --git a/README.md b/README.md index d75861a..e7b2dfe 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,21 @@ You should consult the [WSL documentation](https://docs.microsoft.com/en-us/wind wsl --install -d Ubuntu-22.04 # To get a somewhat recent version of Go ``` +After this, you can open a (wsl) terminal and run the commands: + +```bash +sudo apt update && sudo apt upgrade +sudo apt install -y make podman + +sudo add-apt-repository ppa:longsleep/golang-backports +sudo apt update +sudo apt install golang-go + +# For a recent version of node: +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash +nvm install node +``` + If you get any errors related to virtualization, you will need to enable virtualization in the BIOS. This is a common issue, and you can find a guide for your specific motherboard online. This is a one-time operation and will not affect your windows installation. This setting is usually called "VT-x" or "AMD-V" and is usually found in the CPU settings. If you can't find it, shoot me a message and I'll find it for you. If you're **still dead set** on using a vanilla Windows environment, you will need the following: diff --git a/backend/Makefile b/backend/Makefile index d005846..9cfa335 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -27,6 +27,10 @@ clean: $(GOCLEAN) rm -rf bin rm -f db.sqlite3 + rm -f diagram* + rm -f plantuml.jar + rm -f erd.png + rm -f config.toml # Test target test: db.sqlite3 @@ -54,6 +58,9 @@ migrate: db.sqlite3: make migrate +dbdump: + sqlite3 $(DB_FILE) .dump > database.txt + backup: mkdir -p backups sqlite3 $(DB_FILE) .dump | gzip -9 > ./backups/BACKUP_$(DB_FILE)_$(shell date +"%Y-%m-%d_%H:%M:%S").sql.gz @@ -95,6 +102,18 @@ install-lint: @echo "Installing golangci-lint" @curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(shell go env GOPATH)/bin v1.42.1 +# Fetches the latest plantuml.jar and checks its SHA256 hash +plantuml.jar: + curl -sSfL https://github.com/plantuml/plantuml/releases/download/v1.2024.3/plantuml.jar -o plantuml.jar \ + && echo "519a4a7284c6a0357c369e4bb0caf72c4bfbbde851b8c6d6bbdb7af3c01fc82f plantuml.jar" | sha256sum -c + +# Generate UML diagrams diagral.png & diagram.svg +.PHONY: uml +uml: plantuml.jar + goplantuml -recursive . > diagram.puml + java -jar plantuml.jar -tpng diagram.puml + java -jar plantuml.jar -tsvg diagram.puml + # Convenience target to install just (requires sudo privileges) install-just: @echo "Installing just" diff --git a/backend/internal/database/db.go b/backend/internal/database/db.go index 5a88873..f05530a 100644 --- a/backend/internal/database/db.go +++ b/backend/internal/database/db.go @@ -14,18 +14,17 @@ import ( type Database interface { // Insert a new user into the database, password should be hashed before calling AddUser(username string, password string) error - 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 - // AddTimeReport(projectname string, start time.Time, end time.Time) error - // AddUserToProject(username string, projectname string) error - // ChangeUserRole(username string, projectname string, role string) error - // AddTimeReport(projectname string, start time.Time, end time.Time) error - // AddUserToProject(username string, projectname string) error - // ChangeUserRole(username string, projectname string, role string) error + GetProjectId(projectname string) (int, error) + AddTimeReport(projectName string, userName string, start time.Time, end time.Time) 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) } // This struct is a wrapper type that holds the database connection @@ -34,15 +33,24 @@ type Db struct { *sqlx.DB } +type UserProjectMember struct { + Username string `db:"username"` + UserRole string `db:"p_role"` +} + //go:embed migrations var scripts embed.FS +// TODO: Possibly break these out into separate files bundled with the embed package? 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 = "INSERT INTO activity (report_id, activity_nbr, start_time, end_time, break, comment) VALUES (?, ?, ?, ?, ?, ?)" // WIP -const addUserToProject = "INSERT INTO project_member (project_id, user_id, role) VALUES (?, ?, ?)" // WIP -// const changeUserRole = "" +const addTimeReport = `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), ?, ?);` +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 = ?" // DbConnect connects to the database func DbConnect(dbpath string) Database { @@ -61,8 +69,8 @@ func DbConnect(dbpath string) Database { return &Db{db} } -func (d *Db) AddTimeReport(projectname string, start time.Time, end time.Time, breakTime uint32) error { // WIP - _, err := d.Exec(addTimeReport, projectname, 0, start, end, breakTime, false) +func (d *Db) AddTimeReport(projectName string, userName string, start time.Time, end time.Time) error { // WIP + _, err := d.Exec(addTimeReport, userName, projectName, start, end) return err } @@ -79,13 +87,26 @@ func (d *Db) AddUserToProject(username string, projectname string, role string) panic(err2) } - _, err3 := d.Exec(addUserToProject, projectid, userid, role) + _, err3 := d.Exec(addUserToProject, userid, projectid, role) return err3 } -// func (d *Db) ChangeUserRole(username string, projectname string, role string) error { +func (d *Db) ChangeUserRole(username string, projectname string, role string) error { + var userid int + userid, err := d.GetUserId(username) + if err != nil { + panic(err) + } -// } + var projectid int + projectid, err2 := d.GetProjectId(projectname) + if err2 != nil { + panic(err2) + } + + _, err3 := d.Exec(changeUserRole, role, userid, projectid) + return err3 +} // AddUser adds a user to the database func (d *Db) AddUser(username string, password string) error { @@ -110,9 +131,9 @@ func (d *Db) GetUserId(username string) (int, error) { return id, err } -func (d *Db) GetProjectId(projectname string) (int, error) { // WIP, denna kan vara goof +func (d *Db) GetProjectId(projectname string) (int, error) { var id int - err := d.Get(&id, "SELECT id FROM project WHERE project_name = ?", projectname) + err := d.Get(&id, "SELECT id FROM projects WHERE name = ?", projectname) return id, err } @@ -122,6 +143,69 @@ func (d *Db) AddProject(name string, description string, username string) error return err } +func (d *Db) GetAllUsersProject(projectname string) ([]UserProjectMember, error) { + // Define the SQL query to fetch users and their roles for a given project + query := ` + SELECT u.username, ur.p_role + FROM users u + INNER JOIN user_roles ur ON u.id = ur.user_id + INNER JOIN projects p ON ur.project_id = p.id + WHERE p.name = ? + ` + + // Execute the query + rows, err := d.Queryx(query, projectname) + if err != nil { + return nil, err + } + defer rows.Close() + + // Iterate over the rows and populate the result slice + var users []UserProjectMember + for rows.Next() { + var user UserProjectMember + if err := rows.StructScan(&user); err != nil { + return nil, err + } + users = append(users, user) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return users, nil +} + +// GetAllUsersApplication retrieves all usernames from the database +func (d *Db) GetAllUsersApplication() ([]string, error) { + // Define the SQL query to fetch all usernames + query := ` + SELECT username FROM users + ` + + // Execute the query + rows, err := d.Queryx(query) + if err != nil { + return nil, err + } + defer rows.Close() + + // Iterate over the rows and populate the result slice + var usernames []string + for rows.Next() { + var username string + if err := rows.Scan(&username); err != nil { + return nil, err + } + usernames = append(usernames, username) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return usernames, nil +} + // 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 { diff --git a/backend/internal/database/db_test.go b/backend/internal/database/db_test.go index 96eb9b7..e5aceb2 100644 --- a/backend/internal/database/db_test.go +++ b/backend/internal/database/db_test.go @@ -2,6 +2,7 @@ package database import ( "testing" + "time" ) // Tests are not guaranteed to be sequential @@ -92,14 +93,196 @@ func TestPromoteToAdmin(t *testing.T) { } } -// func TestAddTimeReport(t *testing.T) { +func TestAddTimeReport(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) + } -// func TestAddUserToProject(t *testing.T) { + err = db.AddProject("testproject", "description", "testuser") + if err != nil { + t.Error("AddProject failed:", err) + } -// } + var now = time.Now() + var then = now.Add(time.Hour) -// func TestChangeUserRole(t *testing.T) { + err = db.AddTimeReport("testproject", "testuser", now, then) + if err != nil { + t.Error("AddTimeReport failed:", err) + } +} -// } +func TestAddUserToProject(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) + } + + var now = time.Now() + var then = now.Add(time.Hour) + + err = db.AddTimeReport("testproject", "testuser", now, then) + if err != nil { + t.Error("AddTimeReport failed:", err) + } + + err = db.AddUserToProject("testuser", "testproject", "user") + if err != nil { + t.Error("AddUserToProject failed:", err) + } +} + +func TestChangeUserRole(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.AddUserToProject("testuser", "testproject", "user") + if err != nil { + t.Error("AddUserToProject failed:", err) + } + + err = db.ChangeUserRole("testuser", "testproject", "admin") + if err != nil { + t.Error("ChangeUserRole failed:", err) + } +} + +func TestGetAllUsersProject(t *testing.T) { + db, err := setupState() + if err != nil { + t.Error("setupState failed:", err) + } + + err = db.AddUser("testuser1", "password") + if err != nil { + t.Error("AddUser failed:", err) + } + + err = db.AddUser("testuser2", "password") + if err != nil { + t.Error("AddUser failed:", err) + } + + err = db.AddProject("testproject", "description", "testuser1") + if err != nil { + t.Error("AddProject failed:", err) + } + + err = db.AddUserToProject("testuser1", "testproject", "project_manager") + if err != nil { + t.Error("AddUserToProject failed:", err) + } + + err = db.AddUserToProject("testuser2", "testproject", "user") + if err != nil { + t.Error("AddUserToProject failed:", err) + } + + users, err := db.GetAllUsersProject("testproject") + if err != nil { + t.Error("GetAllUsersProject failed:", err) + } + + // Check if both users are returned with their roles + if len(users) != 2 { + t.Errorf("Expected 2 users, got %d", len(users)) + } + + // Check if testuser1 has project manager role + foundProjectManager := false + for _, user := range users { + if user.Username == "testuser1" && user.UserRole == "project_manager" { + foundProjectManager = true + break + } + } + if !foundProjectManager { + t.Error("Project Manager user not found") + } + + // Check if testuser2 has user role + foundUser := false + for _, user := range users { + if user.Username == "testuser2" && user.UserRole == "user" { + foundUser = true + break + } + } + if !foundUser { + t.Error("User user not found") + } +} + +func TestGetAllUsersApplication(t *testing.T) { + db, err := setupState() + if err != nil { + t.Error("setupState failed:", err) + } + + err = db.AddUser("testuser1", "password") + if err != nil { + t.Error("AddUser failed:", err) + } + + err = db.AddUser("testuser2", "password") + if err != nil { + t.Error("AddUser failed:", err) + } + + users, err := db.GetAllUsersApplication() + if err != nil { + t.Error("GetAllUsersApplication failed:", err) + } + + // Check if both users are returned + if len(users) != 2 { + t.Errorf("Expected 2 users, got %d", len(users)) + } + + // Check if the test users are included in the list + foundTestUser1 := false + foundTestUser2 := false + for _, user := range users { + if user == "testuser1" { + foundTestUser1 = true + } + if user == "testuser2" { + foundTestUser2 = true + } + } + + if !foundTestUser1 { + t.Error("testuser1 not found") + } + if !foundTestUser2 { + t.Error("testuser2 not found") + } +} diff --git a/backend/internal/database/migrations/0020_projects.sql b/backend/internal/database/migrations/0020_projects.sql index adfb818..58d8e97 100644 --- a/backend/internal/database/migrations/0020_projects.sql +++ b/backend/internal/database/migrations/0020_projects.sql @@ -1,11 +1,9 @@ CREATE TABLE IF NOT EXISTS projects ( id INTEGER PRIMARY KEY, - projectId TEXT DEFAULT (HEX(RANDOMBLOB(4))) NOT NULL UNIQUE, name VARCHAR(255) NOT NULL UNIQUE, description TEXT NOT NULL, owner_user_id INTEGER NOT NULL, FOREIGN KEY (owner_user_id) REFERENCES users (id) ); -CREATE INDEX IF NOT EXISTS projects_projectId_index ON projects (projectId); CREATE INDEX IF NOT EXISTS projects_user_id_index ON projects (owner_user_id); \ No newline at end of file diff --git a/backend/internal/database/migrations/0030_time_reports.sql b/backend/internal/database/migrations/0030_time_reports.sql index e8f3ec1..76812a1 100644 --- a/backend/internal/database/migrations/0030_time_reports.sql +++ b/backend/internal/database/migrations/0030_time_reports.sql @@ -1,10 +1,11 @@ CREATE TABLE IF NOT EXISTS time_reports ( id INTEGER PRIMARY KEY, - reportId TEXT DEFAULT (HEX(RANDOMBLOB(6))) NOT NULL UNIQUE, 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 diff --git a/backend/internal/database/migrations/0049_project_role.sql b/backend/internal/database/migrations/0049_project_role.sql index 8716800..f7e7151 100644 --- a/backend/internal/database/migrations/0049_project_role.sql +++ b/backend/internal/database/migrations/0049_project_role.sql @@ -5,5 +5,5 @@ CREATE TABLE IF NOT EXISTS project_role ( ); -- Insert the possible roles a user can have in a project. -INSERT OR IGNORE INTO project_role (p_role) VALUES ('admin'); +INSERT OR IGNORE INTO project_role (p_role) VALUES ('project_manager'); INSERT OR IGNORE INTO project_role (p_role) VALUES ('member'); diff --git a/backend/internal/database/migrations/0050_user_roles.sql b/backend/internal/database/migrations/0050_user_roles.sql index aad25f7..d3e614d 100644 --- a/backend/internal/database/migrations/0050_user_roles.sql +++ b/backend/internal/database/migrations/0050_user_roles.sql @@ -1,7 +1,7 @@ CREATE TABLE IF NOT EXISTS user_roles ( user_id INTEGER NOT NULL, project_id INTEGER NOT NULL, - p_role TEXT NOT NULL, -- 'admin' or 'member' + p_role TEXT NOT NULL, -- 'project_manager' or 'member' FOREIGN KEY (user_id) REFERENCES users (id) FOREIGN KEY (project_id) REFERENCES projects (id) FOREIGN KEY (p_role) REFERENCES project_role (p_role) diff --git a/backend/main.go b/backend/main.go index 1aaca45..bba3fa6 100644 --- a/backend/main.go +++ b/backend/main.go @@ -71,6 +71,7 @@ func main() { server.Post("/api/loginrenew", gs.LoginRenew) server.Delete("/api/userdelete", gs.UserDelete) // Perhaps just use POST to avoid headaches + server.Post("/api/project", gs.CreateProject) // Announce the port we are listening on and start the server err = server.Listen(fmt.Sprintf(":%d", conf.Port)) diff --git a/frontend/src/API/API.ts b/frontend/src/API/API.ts index 2dbd51e..f33c87c 100644 --- a/frontend/src/API/API.ts +++ b/frontend/src/API/API.ts @@ -1,3 +1,4 @@ +import { NewProject, Project } from "../Types/Project"; import { NewUser, User } from "../Types/Users"; // Defines all the methods that an instance of the API must implement @@ -6,6 +7,10 @@ interface API { registerUser(user: NewUser): Promise; /** Remove a user */ removeUser(username: string): Promise; + /** Create a project */ + createProject(project: NewProject): Promise; + /** Renew the token */ + renewToken(token: string): Promise; } // Export an instance of the API @@ -29,4 +34,24 @@ export const api: API = { body: JSON.stringify(username), }).then((res) => res.json() as Promise); }, + + 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 renewToken(token: string): Promise { + return fetch("/api/loginrenew", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + token, + }, + }).then((res) => res.json() as Promise); + }, }; diff --git a/frontend/src/Components/Header.tsx b/frontend/src/Components/Header.tsx index 5c642b8..ba0a939 100644 --- a/frontend/src/Components/Header.tsx +++ b/frontend/src/Components/Header.tsx @@ -15,7 +15,7 @@ function Header({ username }: { username: string }): JSX.Element { > TTIME Logo diff --git a/frontend/src/Components/Register.tsx b/frontend/src/Components/Register.tsx new file mode 100644 index 0000000..d0e3da6 --- /dev/null +++ b/frontend/src/Components/Register.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import { NewUser } from "../Types/Users"; +import { api } from "../API/API"; + +export default function Register(): JSX.Element { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + + const handleRegister = async (): Promise => { + const newUser: NewUser = { userName: username, password }; + await api.registerUser(newUser); // TODO: Handle errors + }; + + return ( +
+
+
{ + e.preventDefault(); + void handleRegister(); + }} + > +

Register new user

+
+ + { + setUsername(e.target.value); + }} + /> +
+
+ + { + setPassword(e.target.value); + }} + /> +
+
+ +
+
+

+
+
+ ); +} diff --git a/frontend/src/Components/TimeReport.tsx b/frontend/src/Components/TimeReport.tsx new file mode 100644 index 0000000..c4ddc38 --- /dev/null +++ b/frontend/src/Components/TimeReport.tsx @@ -0,0 +1,59 @@ +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/Pages/AdminPages/AdminAddProject.tsx b/frontend/src/Pages/AdminPages/AdminAddProject.tsx new file mode 100644 index 0000000..9fd8bed --- /dev/null +++ b/frontend/src/Pages/AdminPages/AdminAddProject.tsx @@ -0,0 +1,26 @@ +import BasicWindow from "../../Components/BasicWindow"; +import Button from "../../Components/Button"; + +function AdminAddProject(): JSX.Element { + const content = <>; + + const buttons = ( + <> +