diff --git a/backend/pkg/db/tasks/tasks.go b/backend/pkg/db/tasks/tasks.go index ee25112..095ad22 100644 --- a/backend/pkg/db/tasks/tasks.go +++ b/backend/pkg/db/tasks/tasks.go @@ -1,41 +1,34 @@ package tasks import ( - "fmt" - "time" - - cache "github.com/jfarleyx/go-simple-cache" "github.com/johannesbuehl/golunteer/backend/pkg/db" ) -type tasksDB struct { - ID int `db:"id"` - Text string `db:"text"` - Enabled bool `db:"enabled"` +type TaskDB struct { + ID int `json:"id" db:"id"` + Task } type Task struct { - Text string `json:"text"` - Enabled bool `json:"enabled"` + Text string `json:"text" db:"text"` + Enabled bool `json:"enabled" db:"enabled"` } -var c *cache.Cache +func GetSlice() ([]TaskDB, error) { + var tasksRaw []TaskDB -func Get() (map[int]Task, error) { - if tasks, hit := c.Get("tasks"); !hit { - refresh() - - return nil, fmt.Errorf("tasks not stored cached") + if err := db.DB.Select(&tasksRaw, "SELECT * FROM TASKS"); err != nil { + return nil, err } else { - return tasks.(map[int]Task), nil + return tasksRaw, nil } + } -func refresh() { - // get the tasksRaw from the database - var tasksRaw []tasksDB - - if err := db.DB.Select(&tasksRaw, "SELECT * FROM TASKS"); err == nil { +func GetMap() (map[int]Task, error) { + if tasksRaw, err := GetSlice(); err != nil { + return nil, err + } else { // convert the result in a map tasks := map[int]Task{} @@ -46,14 +39,18 @@ func refresh() { } } - c.Set("tasks", tasks) + return tasks, nil } } -func init() { - c = cache.New(24 * time.Hour) +func Add(t Task) error { + _, err := db.DB.NamedExec("INSERT INTO TASKS (text, enabled) VALUES (:text, :enabled)", &t) - c.OnExpired(refresh) - - refresh() + return err +} + +func Update(t TaskDB) error { + _, err := db.DB.NamedExec("UPDATE TASKS set text = :text, enabled = :enabled WHERE id = :id", &t) + + return err } diff --git a/backend/pkg/router/router.go b/backend/pkg/router/router.go index a70297d..d6c3a48 100644 --- a/backend/pkg/router/router.go +++ b/backend/pkg/router/router.go @@ -87,11 +87,13 @@ func init() { "events": postEvent, "users": postUser, "availabilities": postAvailabilitie, + "tasks": postTask, }, "PATCH": { "users": patchUser, "events": patchEvent, "availabilities": patchAvailabilities, + "tasks": patchTask, }, "PUT": { "users/password": putPassword, diff --git a/backend/pkg/router/tasks.go b/backend/pkg/router/tasks.go index 6665647..0fc0e73 100644 --- a/backend/pkg/router/tasks.go +++ b/backend/pkg/router/tasks.go @@ -6,15 +6,112 @@ import ( ) func getTasks(args HandlerArgs) responseMessage { - response := responseMessage{} + // check wether the "map"-query is given + if args.C.QueryBool("map") { + if tasks, err := tasks.GetMap(); err != nil { + logger.Error().Msgf("can't get tasks: %v", err) - if tasks, err := tasks.Get(); err != nil { - response.Status = fiber.StatusInternalServerError - - logger.Error().Msgf("can't get tasks: %v", err) + return responseMessage{ + Status: fiber.StatusInternalServerError, + } + } else { + return responseMessage{ + Data: tasks, + } + } } else { - response.Data = tasks - } + if taskSlice, err := tasks.GetSlice(); err != nil { + logger.Error().Msgf("can't get tasks: %v", err) - return response + return responseMessage{ + Status: fiber.StatusInternalServerError, + } + } else { + return responseMessage{ + Data: struct { + Tasks []tasks.TaskDB `json:"tasks"` + }{Tasks: taskSlice}, + } + } + } +} + +func postTask(args HandlerArgs) responseMessage { + // check admin + if !args.User.Admin { + logger.Log().Msgf("user is not admin") + + return responseMessage{ + Status: fiber.StatusUnauthorized, + } + } else { + // parse the body + var task tasks.Task + + if err := args.C.BodyParser(&task); err != nil { + logger.Log().Msgf("can't parse body: %v", err) + + return responseMessage{ + Status: fiber.StatusBadRequest, + } + + // validate the body + } else if err := validate.Struct(&task); err != nil { + logger.Log().Msgf("invalid body: %v", err) + + return responseMessage{ + Status: fiber.StatusBadRequest, + } + + // insert the task into the database + } else if err := tasks.Add(task); err != nil { + logger.Error().Msgf("can't add task: %v", err) + + return responseMessage{ + Status: fiber.StatusInternalServerError, + } + } else { + return responseMessage{} + } + } +} + +func patchTask(args HandlerArgs) responseMessage { + // check admin + if !args.User.Admin { + logger.Log().Msgf("user is not admin") + + return responseMessage{ + Status: fiber.StatusUnauthorized, + } + } else { + // parse the body + var task tasks.TaskDB + + if err := args.C.BodyParser(&task); err != nil { + logger.Log().Msgf("can't parse body: %v", err) + + return responseMessage{ + Status: fiber.StatusBadRequest, + } + + // validate the body + } else if err := validate.Struct(&task); err != nil { + logger.Log().Msgf("invalid body: %v", err) + + return responseMessage{ + Status: fiber.StatusBadRequest, + } + + // insert the task into the database + } else if err := tasks.Update(task); err != nil { + logger.Error().Msgf("can't update task: %v", err) + + return responseMessage{ + Status: fiber.StatusInternalServerError, + } + } else { + return responseMessage{} + } + } } diff --git a/client/src/app/admin/AddAvailability.tsx b/client/src/app/admin/(availabilities)/AddAvailability.tsx similarity index 100% rename from client/src/app/admin/AddAvailability.tsx rename to client/src/app/admin/(availabilities)/AddAvailability.tsx diff --git a/client/src/app/admin/Availabilities.tsx b/client/src/app/admin/(availabilities)/Availabilities.tsx similarity index 95% rename from client/src/app/admin/Availabilities.tsx rename to client/src/app/admin/(availabilities)/Availabilities.tsx index 7564762..515c491 100644 --- a/client/src/app/admin/Availabilities.tsx +++ b/client/src/app/admin/(availabilities)/Availabilities.tsx @@ -1,6 +1,6 @@ import { color2Tailwind, colors } from "@/components/Colorselector"; import { apiCall } from "@/lib"; -import { Edit } from "@carbon/icons-react"; +import { AddLarge, Edit } from "@carbon/icons-react"; import { Button, Checkbox, @@ -78,7 +78,11 @@ export default function Availabilities() { const topContent = ( <> - @@ -90,6 +94,7 @@ export default function Availabilities() { aria-label="Table with the availabilites" shadow="none" isHeaderSticky + isStriped topContent={topContent} sortDescriptor={availabilities.sortDescriptor} onSortChange={availabilities.sort} diff --git a/client/src/app/admin/AvailabilityEditor.tsx b/client/src/app/admin/(availabilities)/AvailabilityEditor.tsx similarity index 100% rename from client/src/app/admin/AvailabilityEditor.tsx rename to client/src/app/admin/(availabilities)/AvailabilityEditor.tsx diff --git a/client/src/app/admin/EditAvailability.tsx b/client/src/app/admin/(availabilities)/EditAvailability.tsx similarity index 75% rename from client/src/app/admin/EditAvailability.tsx rename to client/src/app/admin/(availabilities)/EditAvailability.tsx index 78f84fc..72f7f82 100644 --- a/client/src/app/admin/EditAvailability.tsx +++ b/client/src/app/admin/(availabilities)/EditAvailability.tsx @@ -9,7 +9,7 @@ export default function EditAvailability(props: { onOpenChange?: (isOpen: boolean) => void; onSuccess?: () => void; }) { - async function addAvailability(a: Availability) { + async function updateAvailability(a: Availability) { const result = await apiCall("PATCH", "availabilities", undefined, a); if (result.ok) { @@ -21,7 +21,14 @@ export default function EditAvailability(props: { return ( + Edit Availability{" "} + + "{props.value?.text}" + + + } footer={ + } + isOpen={props.isOpen} + onOpenChange={props.onOpenChange} + onSubmit={addTask} + /> + ); +} diff --git a/client/src/app/admin/(tasks)/EditTask.tsx b/client/src/app/admin/(tasks)/EditTask.tsx new file mode 100644 index 0000000..5668f88 --- /dev/null +++ b/client/src/app/admin/(tasks)/EditTask.tsx @@ -0,0 +1,43 @@ +import { apiCall, Task } from "@/lib"; +import { Button } from "@heroui/react"; +import { Renew } from "@carbon/icons-react"; +import TaskEditor from "./TaskEditor"; + +export default function EditTask(props: { + value: Task | undefined; + isOpen?: boolean; + onOpenChange?: (isOpen: boolean) => void; + onSuccess?: () => void; +}) { + async function updateTask(t: Task) { + const result = await apiCall("PATCH", "tasks", undefined, t); + + if (result.ok) { + props.onSuccess?.(); + props.onOpenChange?.(false); + } + } + + return ( + + Edit Task{" "} + + "{props.value?.text}" + + + } + footer={ + + } + value={props.value} + isOpen={props.isOpen} + onOpenChange={props.onOpenChange} + onSubmit={updateTask} + /> + ); +} diff --git a/client/src/app/admin/(tasks)/TaskEditor.tsx b/client/src/app/admin/(tasks)/TaskEditor.tsx new file mode 100644 index 0000000..84513dc --- /dev/null +++ b/client/src/app/admin/(tasks)/TaskEditor.tsx @@ -0,0 +1,80 @@ +import { Task } from "@/lib"; +import { + Checkbox, + Form, + Input, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from "@heroui/react"; +import React, { FormEvent, useState } from "react"; + +export default function TaskEditor(props: { + header: React.ReactNode; + footer: React.ReactNode; + value?: Task; + isOpen?: boolean; + onOpenChange?: (isOpen: boolean) => void; + onSubmit?: (e: Task) => void; +}) { + const [text, setText] = useState(props.value?.text); + const [enabled, setEnabled] = useState(props.value?.enabled ?? true); + + function submit(e: FormEvent) { + const formData = Object.fromEntries(new FormData(e.currentTarget)) as { + text: string; + color: string; + enabled: string; + }; + + props.onSubmit?.({ + ...formData, + id: props.value?.id, + enabled: formData.enabled == "true", + }); + } + + return ( + +
{ + e.preventDefault(); + submit(e); + }} + className="w-fit border-2" + > + + +

{props.header}

+
+ + + + Enabled + + + {props.footer} +
+
+
+ ); +} diff --git a/client/src/app/admin/(tasks)/Tasks.tsx b/client/src/app/admin/(tasks)/Tasks.tsx new file mode 100644 index 0000000..c1ba1a6 --- /dev/null +++ b/client/src/app/admin/(tasks)/Tasks.tsx @@ -0,0 +1,139 @@ +import { apiCall, Task } from "@/lib"; +import { AddLarge, Edit } from "@carbon/icons-react"; +import { + Button, + Checkbox, + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, + Tooltip, +} from "@heroui/react"; +import { useAsyncList } from "@react-stately/data"; +import { useState } from "react"; +import AddTask from "./AddTask"; +import EditTask from "./EditTask"; + +export default function Tasks() { + const [showAddTask, setShowAddTask] = useState(false); + const [editTask, setEditTask] = useState(); + + const tasks = useAsyncList({ + async load() { + const result = await apiCall("GET", "tasks"); + + if (result.ok) { + const json = await result.json(); + + return { + items: json.tasks, + }; + } else { + return { + items: [], + }; + } + }, + async sort({ items, sortDescriptor }) { + return { + items: items.sort((a, b) => { + let cmp = 0; + + switch (sortDescriptor.column) { + case "text": + cmp = a.text.localeCompare(b.text); + break; + case "enabled": + if (a.enabled && !b.enabled) { + cmp = -1; + } else if (!a.enabled && b.enabled) { + cmp = 1; + } + break; + } + + if (sortDescriptor.direction === "descending") { + cmp *= -1; + } + + return cmp; + }), + }; + }, + }); + + const topContent = ( + <> + + + ); + + return ( +
+ + + + Text + + + Enabled + + + Edit + + + + {(task) => ( + + {task.text} + + + + + + + + )} + +
+ + + + (!isOpen ? setEditTask(undefined) : null)} + onSuccess={tasks.reload} + /> +
+ ); +} diff --git a/client/src/app/admin/AddUser.tsx b/client/src/app/admin/(users)/AddUser.tsx similarity index 100% rename from client/src/app/admin/AddUser.tsx rename to client/src/app/admin/(users)/AddUser.tsx diff --git a/client/src/app/admin/EditUser.tsx b/client/src/app/admin/(users)/EditUser.tsx similarity index 100% rename from client/src/app/admin/EditUser.tsx rename to client/src/app/admin/(users)/EditUser.tsx diff --git a/client/src/app/admin/Users.tsx b/client/src/app/admin/(users)/Users.tsx similarity index 93% rename from client/src/app/admin/Users.tsx rename to client/src/app/admin/(users)/Users.tsx index fc1d334..f6d25f5 100644 --- a/client/src/app/admin/Users.tsx +++ b/client/src/app/admin/(users)/Users.tsx @@ -14,7 +14,7 @@ import { import { useAsyncList } from "@react-stately/data"; import { FormEvent, useState } from "react"; import AddUser from "./AddUser"; -import { Edit } from "@carbon/icons-react"; +import { AddLarge, Edit } from "@carbon/icons-react"; import EditUser from "./EditUser"; export default function Users() { @@ -81,7 +81,13 @@ export default function Users() { // content above the user-tabel const topContent = ( <> - + ); @@ -91,6 +97,7 @@ export default function Users() { aria-label="Table with all users" shadow="none" isHeaderSticky + isStriped topContent={topContent} sortDescriptor={users.sortDescriptor} onSortChange={users.sort} diff --git a/client/src/app/admin/page.tsx b/client/src/app/admin/page.tsx index a827de4..9fcf5f0 100644 --- a/client/src/app/admin/page.tsx +++ b/client/src/app/admin/page.tsx @@ -1,18 +1,23 @@ "use client"; import { Tab, Tabs } from "@heroui/react"; -import Users from "./Users"; -import Availabilities from "./Availabilities"; +import Users from "./(users)/Users"; +import Availabilities from "./(availabilities)/Availabilities"; +import Tasks from "./(tasks)/Tasks"; export default function AdminDashboard() { return (
- + - Tasks - + + + + + +
); diff --git a/client/src/app/assignments/page.tsx b/client/src/app/assignments/page.tsx index 4289d56..71b0bef 100644 --- a/client/src/app/assignments/page.tsx +++ b/client/src/app/assignments/page.tsx @@ -3,7 +3,7 @@ import AddEvent from "@/components/Event/AddEvent"; import EditEvent, { EventSubmitData } from "@/components/Event/EditEvent"; import LocalDate from "@/components/LocalDate"; -import { apiCall, getTasks } from "@/lib"; +import { apiCall, getTaskMap } from "@/lib"; import { EventData } from "@/Zustand"; import { Add, @@ -48,7 +48,7 @@ export default function AdminPanel() { // get the available tasks and craft them into the headers const headers = useAsyncList({ async load() { - const tasks = await getTasks(); + const tasks = await getTaskMap(); return { items: [ diff --git a/client/src/components/Event/EditEvent.tsx b/client/src/components/Event/EditEvent.tsx index 7011642..9850a3a 100644 --- a/client/src/components/Event/EditEvent.tsx +++ b/client/src/components/Event/EditEvent.tsx @@ -19,7 +19,7 @@ import { Spinner, Textarea, } from "@heroui/react"; -import { getTasks, Task } from "@/lib"; +import { getTaskMap, Task } from "@/lib"; import { EventData } from "@/Zustand"; export interface EventSubmitData { @@ -103,7 +103,7 @@ export default function EditEvent(props: { // get the available tasks and initialize the state with them useEffect(() => { (async () => { - const tasks = await getTasks(); + const tasks = await getTaskMap(); setTasksMap(tasks); diff --git a/client/src/lib.ts b/client/src/lib.ts index 296295c..aac6f9d 100644 --- a/client/src/lib.ts +++ b/client/src/lib.ts @@ -94,12 +94,13 @@ export function vaidatePassword(password: string): string[] { } export interface Task { + id: number | undefined; text: string; enabled: boolean; } -export async function getTasks(): Promise> { - const result = await apiCall("GET", "tasks"); +export async function getTaskMap(): Promise> { + const result = await apiCall("GET", "tasks", { map: true }); if (result.ok) { const tasks = await result.json();