diff --git a/backend/pkg/db/assignments/assignments.go b/backend/pkg/db/assignments/assignments.go index 508c7a9..11c8b35 100644 --- a/backend/pkg/db/assignments/assignments.go +++ b/backend/pkg/db/assignments/assignments.go @@ -6,14 +6,14 @@ import ( type assignments map[string]*string -type assignemntDB struct { +type eventAssignmentDB struct { TaskName string `db:"taskName"` UserName *string `db:"userName"` } func Event(eventID int) (assignments, error) { // get the assignments from the database - var assignmentRows []assignemntDB + var assignmentRows []eventAssignmentDB if err := db.DB.Select(&assignmentRows, "SELECT USERS.name AS userName, TASKS.text AS taskName FROM USER_ASSIGNMENTS LEFT JOIN USERS ON USER_ASSIGNMENTS.userName = USERS.name LEFT JOIN TASKS ON USER_ASSIGNMENTS.taskID = TASKS.id WHERE USER_ASSIGNMENTS.eventID = ?", eventID); err != nil { return nil, err diff --git a/backend/pkg/db/events/events.go b/backend/pkg/db/events/events.go index 74755f7..163f5b7 100644 --- a/backend/pkg/db/events/events.go +++ b/backend/pkg/db/events/events.go @@ -1,6 +1,8 @@ package events import ( + "slices" + "github.com/johannesbuehl/golunteer/backend/pkg/db" "github.com/johannesbuehl/golunteer/backend/pkg/db/assignments" "github.com/johannesbuehl/golunteer/backend/pkg/db/availabilities" @@ -18,7 +20,7 @@ type EventWithAvailabilities struct { } type eventDataDB struct { - Id int `db:"id" json:"id"` + ID int `db:"id" json:"id" validate:"required"` Date string `db:"date" json:"date" validate:"required"` Description string `db:"description" json:"description"` } @@ -26,7 +28,7 @@ type eventDataDB struct { // transform the database-entry to an Event func (e eventDataDB) Event() (EventWithAssignment, error) { // get the assignments associated with the event - if assignemnts, err := assignments.Event(e.Id); err != nil { + if assignemnts, err := assignments.Event(e.ID); err != nil { return EventWithAssignment{}, err } else { return EventWithAssignment{ @@ -42,7 +44,7 @@ func (e eventDataDB) EventWithAvailabilities() (EventWithAvailabilities, error) return EventWithAvailabilities{}, err // get the availabilities - } else if availabilities, err := availabilities.Event(e.Id); err != nil { + } else if availabilities, err := availabilities.Event(e.ID); err != nil { return EventWithAvailabilities{}, err } else { return EventWithAvailabilities{ @@ -53,8 +55,9 @@ func (e eventDataDB) EventWithAvailabilities() (EventWithAvailabilities, error) } type EventCreate struct { - eventDataDB - Tasks []int `json:"tasks" validate:"required,min=1"` + Date string `db:"date" json:"date" validate:"required"` + Description string `db:"description" json:"description"` + Tasks []int `json:"tasks" validate:"required,min=1"` } func Create(event EventCreate) error { @@ -91,6 +94,69 @@ func Create(event EventCreate) error { return nil } +type EventPatch struct { + eventDataDB + Tasks []int `json:"tasks" validate:"required,min=1"` +} + +func Update(event EventPatch) error { + // update the event itself + if _, err := db.DB.NamedExec("UPDATE EVENTS SET description = :description, date = :date WHERE id = :id", event); err != nil { + return err + + // get the tasks currently assigned to the event + } else { + type TaskID struct { + ID int `db:"taskID"` + } + + var taskRows []TaskID + + if err := db.DB.Select(&taskRows, "SELECT taskID FROM USER_ASSIGNMENTS WHERE eventID = ?", event.ID); err != nil { + return err + } else { + type Task struct { + TaskID + EventID int `db:"eventID"` + } + + // extract the rows that need to be deleted + deleteRows := []Task{} + + for _, row := range taskRows { + if !slices.Contains(event.Tasks, row.ID) { + deleteRows = append(deleteRows, Task{TaskID: row, EventID: event.ID}) + } + } + + // extract the rows that need to be created + createRows := []Task{} + + for _, id := range event.Tasks { + if !slices.Contains(taskRows, TaskID{ID: id}) { + createRows = append(createRows, Task{TaskID: TaskID{ID: id}, EventID: event.ID}) + } + } + + // delete the no longer needed rows + if len(deleteRows) > 0 { + if _, err := db.DB.NamedExec("DELETE FROM USER_ASSIGNMENTS WHERE eventID = :eventID AND taskID = :taskID", deleteRows); err != nil { + return err + } + } + + // create the new tasks + if len(createRows) > 0 { + if _, err := db.DB.NamedExec("INSERT INTO USER_ASSIGNMENTS (eventID, taskID) VALUES (:eventID, :taskID)", createRows); err != nil { + return err + } + } + + return nil + } + } +} + func All() ([]eventDataDB, error) { var dbRows []eventDataDB @@ -110,7 +176,7 @@ func WithAssignments() ([]EventWithAssignment, error) { for ii, e := range eventsDB { if ev, err := e.Event(); err != nil { - logger.Logger.Error().Msgf("can't get assignments for event with id = %d: %v", e.Id, err) + logger.Logger.Error().Msgf("can't get assignments for event with id = %d: %v", e.ID, err) } else { events[ii] = ev } @@ -129,7 +195,7 @@ func WithAvailabilities() ([]EventWithAvailabilities, error) { for ii, e := range eventsDB { if ev, err := e.EventWithAvailabilities(); err != nil { - logger.Logger.Error().Msgf("can't get availabilities for event with id = %d: %v", e.Id, err) + logger.Logger.Error().Msgf("can't get availabilities for event with id = %d: %v", e.ID, err) } else { events[ii] = ev } diff --git a/backend/pkg/db/tasks/tasks.go b/backend/pkg/db/tasks/tasks.go index b8f7a84..878c15a 100644 --- a/backend/pkg/db/tasks/tasks.go +++ b/backend/pkg/db/tasks/tasks.go @@ -9,7 +9,7 @@ import ( ) type tasksDB struct { - Id int `db:"id"` + ID int `db:"id"` Text string `db:"text"` Disabled bool `db:"disabled"` } @@ -40,7 +40,7 @@ func refresh() { tasks := map[int]Task{} for _, a := range tasksRaw { - tasks[a.Id] = Task{ + tasks[a.ID] = Task{ Text: a.Text, Disabled: a.Disabled, } diff --git a/backend/pkg/router/events.go b/backend/pkg/router/events.go index 3b980d7..1e63baf 100644 --- a/backend/pkg/router/events.go +++ b/backend/pkg/router/events.go @@ -8,40 +8,68 @@ import ( func postEvent(args HandlerArgs) responseMessage { response := responseMessage{} - // write the event - var body events.EventCreate - - // try to parse the body - if err := args.C.BodyParser(&body); err != nil { - response.Status = fiber.StatusBadRequest - - logger.Log().Msgf("can't parse body: %v", err) - - // validate the parsed body - } else if err := validate.Struct(body); err != nil { - response.Status = fiber.StatusBadRequest - - logger.Log().Msgf("invalid body: %v", err) - - // create the event - } else if err := events.Create(body); err != nil { - response.Status = fiber.StatusInternalServerError - - logger.Error().Msgf("can't create event: %v", err) + // check admin + if !args.User.Admin { + response.Status = fiber.StatusForbidden } else { - // respond with the new events - if events, err := events.WithAssignments(); err != nil { + + // write the event + var body events.EventCreate + + // try to parse the body + if err := args.C.BodyParser(&body); err != nil { + response.Status = fiber.StatusBadRequest + + logger.Log().Msgf("can't parse body: %v", err) + + // validate the parsed body + } else if err := validate.Struct(body); err != nil { + response.Status = fiber.StatusBadRequest + + logger.Log().Msgf("invalid body: %v", err) + + // create the event + } else if err := events.Create(body); err != nil { response.Status = fiber.StatusInternalServerError - logger.Error().Msgf("can't retrieve events: %v", err) - } else { - response.Data = events + logger.Error().Msgf("can't create event: %v", err) } } return response } +func patchEvent(args HandlerArgs) responseMessage { + response := responseMessage{} + + // check admin + if !args.User.Admin { + response.Status = fiber.StatusForbidden + } else { + // parse the body + var body events.EventPatch + + if err := args.C.BodyParser(&body); err != nil { + response.Status = fiber.StatusBadRequest + + logger.Log().Msgf("can't parse body: %v", err) + + // validate the body + } else if err := validate.Struct(body); err != nil { + response.Status = fiber.StatusBadRequest + + logger.Log().Msgf("ivnalid body: %v", err) + + // update the event + } else if err := events.Update(body); err != nil { + response.Status = fiber.StatusInternalServerError + + logger.Error().Msgf("updating the event failed: %v", err) + } + } + return response +} + func getEventsAssignments(args HandlerArgs) responseMessage { response := responseMessage{} diff --git a/backend/pkg/router/router.go b/backend/pkg/router/router.go index 6fc5094..b73832f 100644 --- a/backend/pkg/router/router.go +++ b/backend/pkg/router/router.go @@ -69,6 +69,7 @@ func init() { "GET": app.Get, "POST": app.Post, "PATCH": app.Patch, + "PUT": app.Put, "DELETE": app.Delete, } @@ -86,8 +87,11 @@ func init() { "users": postUser, }, "PATCH": { - "users/password": patchPassword, - "users": patchUser, + "users": patchUser, + "events": patchEvent, + }, + "PUT": { + "users/password": putPassword, }, "DELETE": { "event": deleteEvent, diff --git a/backend/pkg/router/user.go b/backend/pkg/router/user.go index 2dee62d..bfe2833 100644 --- a/backend/pkg/router/user.go +++ b/backend/pkg/router/user.go @@ -54,7 +54,7 @@ func postUser(args HandlerArgs) responseMessage { return response } -func patchPassword(args HandlerArgs) responseMessage { +func putPassword(args HandlerArgs) responseMessage { response := responseMessage{} // parse the body var body users.UserChangePassword diff --git a/client/src/app/account/page.tsx b/client/src/app/account/page.tsx index d698513..249b532 100644 --- a/client/src/app/account/page.tsx +++ b/client/src/app/account/page.tsx @@ -19,7 +19,7 @@ export default function Account() { async function changePassword(e: FormEvent) { const data = Object.fromEntries(new FormData(e.currentTarget)); - const result = await apiCall("PATCH", "users/password", undefined, data); + const result = await apiCall("PUT", "users/password", undefined, data); if (result.ok) { setPassword(""); @@ -28,9 +28,9 @@ export default function Account() { return ( <> -

Account

+

Account

- +

Change Password

diff --git a/client/src/app/assignments/page.tsx b/client/src/app/assignments/page.tsx index 5a89e5a..6269026 100644 --- a/client/src/app/assignments/page.tsx +++ b/client/src/app/assignments/page.tsx @@ -1,6 +1,7 @@ "use client"; import AddEvent from "@/components/Event/AddEvent"; +import EditEvent, { EventSubmitData } from "@/components/Event/EditEvent"; import LocalDate from "@/components/LocalDate"; import { apiCall, getTasks } from "@/lib"; import { EventData } from "@/Zustand"; @@ -10,6 +11,7 @@ import { Copy, Edit, NotAvailable, + Renew, TrashCan, } from "@carbon/icons-react"; import { @@ -59,6 +61,10 @@ function availability2Color(availability?: Availability) { } export default function AdminPanel() { + const [showAddEvent, setShowAddEvent] = useState(false); + const [editEvent, setEditEvent] = useState(); + const [deleteEvent, setDeleteEvent] = useState(); + // get the available tasks and craft them into the headers const headers = useAsyncList({ async load() { @@ -115,14 +121,14 @@ export default function AdminPanel() { }); // send a delete request to the backend and close the popup on success - async function deleteEvent(eventId: number) { - const result = await apiCall("DELETE", "event", { id: eventId }); + async function sendDeleteEvent() { + if (deleteEvent !== undefined) { + const result = await apiCall("DELETE", "event", { id: deleteEvent.id }); - if (result.ok) { - // store the received events - events.reload(); - - setShowDeleteConfirm(false); + if (result.ok) { + // store the received events + events.reload(); + } } } @@ -141,12 +147,20 @@ export default function AdminPanel() { ); case "description": - return {event[key]}; + return ( +
+ {event[key]} +
+ ); case "actions": return (
- - - - - - ) : null} + (!isOpen ? setEditEvent(undefined) : null)} + onSubmit={updateEvent} + initialState={editEvent} + footer={ + + } + > + Edit Event + + + (!isOpen ? setDeleteEvent(undefined) : null)} + shadow={"none" as "sm"} + backdrop="blur" + className="bg-accent-5" + > + + +

Confirm event deletion

+
+ + The event{" "} + + + {deleteEvent?.date} + + {" "} + will be deleted. + + + + + +
+
); } diff --git a/client/src/components/Event/AddEvent.tsx b/client/src/components/Event/AddEvent.tsx index 47a6276..4557552 100644 --- a/client/src/components/Event/AddEvent.tsx +++ b/client/src/components/Event/AddEvent.tsx @@ -1,33 +1,7 @@ -import { useEffect, useReducer, useState } from "react"; -import { Add } from "@carbon/icons-react"; -import zustand from "../../Zustand"; -import { getLocalTimeZone, now, ZonedDateTime } from "@internationalized/date"; -import { - Button, - Checkbox, - CheckboxGroup, - DatePicker, - Form, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - Spinner, - Textarea, -} from "@nextui-org/react"; -import { apiCall, getTasks, Task } from "@/lib"; - -interface state { - date: ZonedDateTime; - description: string; - tasks: string[]; -} - -interface dispatchAction { - action: "set" | "reset"; - value?: Partial; -} +import { Button } from "@nextui-org/react"; +import EditEvent, { EventSubmitData } from "./EditEvent"; +import { apiCall } from "@/lib"; +import { AddLarge } from "@carbon/icons-react"; export default function AddEvent(props: { className?: string; @@ -35,138 +9,32 @@ export default function AddEvent(props: { onOpenChange: (isOpen: boolean) => void; onSuccess?: () => void; }) { - // initial state for the inputs - const initialState: state = { - date: now(getLocalTimeZone()), - description: "", - tasks: [], - }; - - // handle state dispatches - function reducer(state: state, action: dispatchAction): state { - if (action.action === "reset") { - return initialState; - } else { - return { ...state, ...action.value }; - } - } - const [state, dispatchState] = useReducer(reducer, initialState); - const [tasks, setTasks] = useState>({}); - - // get the available tasks - useEffect(() => { - (async () => { - setTasks(await getTasks()); - })(); - }, []); - - // sends the addEvent request to the backend - async function addEvent() { - const data = { - ...state, - tasks: state.tasks.map((task) => parseInt(task)), - date: state.date.toAbsoluteString().slice(0, -1), - }; - + async function addEvent(data: EventSubmitData) { const result = await apiCall("POST", "events", undefined, data); if (result.ok) { - zustand.getState().setEvents(await result.json()); - props.onOpenChange(false); props.onSuccess?.(); } } - // reset the state when the modal gets closed - useEffect(() => { - if (!props.isOpen) { - dispatchState({ action: "reset" }); - } - }, [props.isOpen]); - return ( - void addEvent(data)} + footer={ + + } > -
{ - e.preventDefault(); - void addEvent(); - }} - > - - -

Add Event

-
- - - - !!dt - ? dispatchState({ action: "set", value: { date: dt } }) - : null - } - /> -