diff --git a/backend/pkg/db/db.go b/backend/pkg/db/db.go index 0563ef4..9b624bc 100644 --- a/backend/pkg/db/db.go +++ b/backend/pkg/db/db.go @@ -1,19 +1,13 @@ package db import ( - "database/sql" - "fmt" - "reflect" - "strings" "time" "github.com/go-sql-driver/mysql" "github.com/jmoiron/sqlx" _config "github.com/johannesbuehl/golunteer/backend/pkg/config" - _logger "github.com/johannesbuehl/golunteer/backend/pkg/logger" ) -var logger = _logger.Logger var config = _config.Config // connection to database @@ -37,189 +31,3 @@ func init() { DB.SetConnMaxLifetime(time.Minute) } - -// query the database -func SelectOld[T any](table string, where string, args ...any) ([]T, error) { - // validate columns against struct T - tType := reflect.TypeOf(new(T)).Elem() - columns := make([]string, tType.NumField()) - - validColumns := make(map[string]any) - for ii := 0; ii < tType.NumField(); ii++ { - field := tType.Field(ii) - validColumns[strings.ToLower(field.Name)] = struct{}{} - columns[ii] = strings.ToLower(field.Name) - } - - for _, col := range columns { - if _, ok := validColumns[strings.ToLower(col)]; !ok { - return nil, fmt.Errorf("invalid column: %s for struct type %T", col, new(T)) - } - } - - // create the query - completeQuery := fmt.Sprintf("SELECT %s FROM %s", strings.Join(columns, ", "), table) - - if where != "" && where != "*" { - completeQuery = fmt.Sprintf("%s WHERE %s", completeQuery, where) - } - - var rows *sql.Rows - var err error - - if len(args) > 0 { - DB.Ping() - - rows, err = DB.Query(completeQuery, args...) - } else { - DB.Ping() - - rows, err = DB.Query(completeQuery) - } - - if err != nil { - logger.Error().Msgf("database access failed with error %v", err) - - return nil, err - } - - defer rows.Close() - results := []T{} - - for rows.Next() { - var lineResult T - - scanArgs := make([]any, len(columns)) - v := reflect.ValueOf(&lineResult).Elem() - - for ii, col := range columns { - field := v.FieldByName(col) - - if field.IsValid() && field.CanSet() { - scanArgs[ii] = field.Addr().Interface() - } else { - logger.Warn().Msgf("Field %s not found in struct %T", col, lineResult) - scanArgs[ii] = new(any) // save dummy value - } - } - - // scan the row into the struct - if err := rows.Scan(scanArgs...); err != nil { - logger.Warn().Msgf("Scan-error: %v", err) - - return nil, err - } - - results = append(results, lineResult) - } - - if err := rows.Err(); err != nil { - logger.Error().Msgf("rows-error: %v", err) - return nil, err - } else { - return results, nil - } -} - -// insert data intot the databse -func Insert(table string, vals any) error { - // extract columns from vals - v := reflect.ValueOf(vals) - t := v.Type() - - columns := make([]string, t.NumField()) - values := make([]any, t.NumField()) - - for ii := 0; ii < t.NumField(); ii++ { - fieldValue := v.Field(ii) - - field := t.Field(ii) - - columns[ii] = strings.ToLower(field.Name) - values[ii] = fieldValue.Interface() - } - - placeholders := strings.Repeat(("?, "), len(columns)) - placeholders = strings.TrimSuffix(placeholders, ", ") - - completeQuery := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, strings.Join(columns, ", "), placeholders) - - _, err := DB.Exec(completeQuery, values...) - - return err -} - -// update data in the database -func Update(table string, set, where any) error { - setV := reflect.ValueOf(set) - setT := setV.Type() - - setColumns := make([]string, setT.NumField()) - setValues := make([]any, setT.NumField()) - - for ii := 0; ii < setT.NumField(); ii++ { - fieldValue := setV.Field(ii) - - field := setT.Field(ii) - - setColumns[ii] = strings.ToLower(field.Name) + " = ?" - setValues[ii] = fieldValue.Interface() - } - - whereV := reflect.ValueOf(where) - whereT := whereV.Type() - - whereColumns := make([]string, whereT.NumField()) - whereValues := make([]any, whereT.NumField()) - - for ii := 0; ii < whereT.NumField(); ii++ { - fieldValue := whereV.Field(ii) - - // skip empty (zero) values - if !fieldValue.IsZero() { - field := whereT.Field(ii) - - whereColumns[ii] = strings.ToLower(field.Name) + " = ?" - whereValues[ii] = fmt.Sprint(fieldValue.Interface()) - } - } - - sets := strings.Join(setColumns, ", ") - wheres := strings.Join(whereColumns, " AND ") - - placeholderValues := append(setValues, whereValues...) - - completeQuery := fmt.Sprintf("UPDATE %s SET %s WHERE %s", table, sets, wheres) - - _, err := DB.Exec(completeQuery, placeholderValues...) - - return err -} - -// remove data from the database -func Delete(table string, vals any) error { - // extract columns from vals - v := reflect.ValueOf(vals) - t := v.Type() - - columns := make([]string, t.NumField()) - values := make([]any, t.NumField()) - - for ii := 0; ii < t.NumField(); ii++ { - fieldValue := v.Field(ii) - - // skip empty (zero) values - if !fieldValue.IsZero() { - field := t.Field(ii) - - columns[ii] = strings.ToLower(field.Name) + " = ?" - values[ii] = fmt.Sprint(fieldValue.Interface()) - } - } - - completeQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", table, strings.Join(columns, ", ")) - - _, err := DB.Exec(completeQuery, values...) - - return err -} diff --git a/backend/pkg/db/events/events.go b/backend/pkg/db/events/events.go index 9d1043f..24e3b1a 100644 --- a/backend/pkg/db/events/events.go +++ b/backend/pkg/db/events/events.go @@ -13,7 +13,7 @@ type EventWithAssignment struct { type eventDataDB struct { Id int `db:"id" json:"id"` - Date string `db:"date" json:"date"` + Date string `db:"date" json:"date" validate:"required"` Description string `db:"description" json:"description"` } @@ -30,6 +30,45 @@ func (e *eventDataDB) Event() (EventWithAssignment, error) { } } +type EventCreate struct { + eventDataDB + Tasks []int `json:"tasks" validate:"required,min=1"` +} + +func Create(event EventCreate) error { + if result, err := db.DB.NamedExec("INSERT INTO EVENTS (date, description) VALUES (:date, :description)", event); err != nil { + return err + } else if id, err := result.LastInsertId(); err != nil { + return err + } else { + // create an insert-slice with the id included + tasks := []struct { + TaskID int `db:"taskID"` + EventID int64 `db:"eventID"` + }{} + + for _, taskID := range event.Tasks { + tasks = append(tasks, struct { + TaskID int "db:\"taskID\"" + EventID int64 "db:\"eventID\"" + }{ + TaskID: taskID, + EventID: id, + }) + } + + // create the assignments + if _, err := db.DB.NamedExec("INSERT INTO USER_ASSIGNMENTS (eventID, taskID) VALUES (:eventID, :taskID)", tasks); err != nil { + // delete the event again + db.DB.Query("DELETE FROM EVENTS WHERE id = ?", id) + + return err + } + } + + return nil +} + func All() ([]eventDataDB, error) { var dbRows []eventDataDB diff --git a/backend/pkg/db/tasks/tasks.go b/backend/pkg/db/tasks/tasks.go index 2f36311..b8f7a84 100644 --- a/backend/pkg/db/tasks/tasks.go +++ b/backend/pkg/db/tasks/tasks.go @@ -15,8 +15,8 @@ type tasksDB struct { } type Task struct { - Text string - Disabled bool + Text string `json:"text"` + Disabled bool `json:"disabled"` } var c *cache.Cache diff --git a/backend/pkg/router/events.go b/backend/pkg/router/events.go index 7273455..492133c 100644 --- a/backend/pkg/router/events.go +++ b/backend/pkg/router/events.go @@ -5,6 +5,43 @@ import ( "github.com/johannesbuehl/golunteer/backend/pkg/db/events" ) +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) + } else { + // respond with the new events + if events, err := events.WithAssignments(); err != nil { + response.Status = fiber.StatusInternalServerError + + logger.Error().Msgf("can't retrieve events: %v", err) + } else { + response.Data = events + } + } + + return response +} + func getEventsAssignments(args HandlerArgs) responseMessage { response := responseMessage{} diff --git a/backend/pkg/router/router.go b/backend/pkg/router/router.go index 3515e92..8521506 100644 --- a/backend/pkg/router/router.go +++ b/backend/pkg/router/router.go @@ -74,8 +74,12 @@ func init() { // map with the individual registered endpoints endpoints := map[string]map[string]func(HandlerArgs) responseMessage{ - "GET": {"events/assignments": getEventsAssignments, "events/user/pending": getEventsUserPending}, - "POST": {}, + "GET": { + "events/assignments": getEventsAssignments, + "events/user/pending": getEventsUserPending, + "tasks": getTasks, + }, + "POST": {"events": postEvent}, "PATCH": {}, "DELETE": {}, } diff --git a/backend/pkg/router/tasks.go b/backend/pkg/router/tasks.go new file mode 100644 index 0000000..6665647 --- /dev/null +++ b/backend/pkg/router/tasks.go @@ -0,0 +1,20 @@ +package router + +import ( + "github.com/gofiber/fiber/v2" + "github.com/johannesbuehl/golunteer/backend/pkg/db/tasks" +) + +func getTasks(args HandlerArgs) responseMessage { + response := responseMessage{} + + if tasks, err := tasks.Get(); err != nil { + response.Status = fiber.StatusInternalServerError + + logger.Error().Msgf("can't get tasks: %v", err) + } else { + response.Data = tasks + } + + return response +} diff --git a/client/src/Zustand.ts b/client/src/Zustand.ts index e626166..f489069 100644 --- a/client/src/Zustand.ts +++ b/client/src/Zustand.ts @@ -6,14 +6,6 @@ import { persist } from "zustand/middleware"; export type Task = string; -export const Tasks: Task[] = [ - "Audio", - "Livestream", - "Camera", - "Light", - "Stream Audio", -]; - export type Availability = string; export const Availabilities: Availability[] = ["yes", "maybe", "no"]; @@ -21,7 +13,7 @@ export const Availabilities: Availability[] = ["yes", "maybe", "no"]; export interface EventData { id: number; date: string; - tasks: Partial>; + tasks: Partial>; description: string; } @@ -32,6 +24,7 @@ interface Zustand { userName: string; admin: boolean; } | null; + tasks?: Record; setEvents: (events: EventData[]) => void; reset: (zustand?: Partial) => void; setPendingEvents: (c: number) => void; diff --git a/client/src/app/events/page.tsx b/client/src/app/events/page.tsx index 6319307..d2a5247 100644 --- a/client/src/app/events/page.tsx +++ b/client/src/app/events/page.tsx @@ -35,7 +35,9 @@ export default function Events() {
{events.map((ee, ii) => ( - +
+ +
))}
diff --git a/client/src/components/Event/AddEvent.tsx b/client/src/components/Event/AddEvent.tsx index 0e670f3..371d5e8 100644 --- a/client/src/components/Event/AddEvent.tsx +++ b/client/src/components/Event/AddEvent.tsx @@ -1,19 +1,22 @@ -import { useState } from "react"; +import { useEffect, useReducer } from "react"; import { Add } from "@carbon/icons-react"; -import zustand, { EventData, Task, Tasks } from "../../Zustand"; +import zustand, { Task } 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 } from "@/lib"; interface state { date: ZonedDateTime; @@ -21,34 +24,76 @@ interface state { tasks: Task[]; } +interface dispatchAction { + action: "set" | "reset"; + value?: Partial; +} + export default function AddEvent(props: { className?: string; isOpen: boolean; onOpenChange: (isOpen: boolean) => void; }) { - const [state, setState] = useState({ + // initial state for the inputs + const initialState: state = { date: now(getLocalTimeZone()), description: "", tasks: [], - }); + }; - function addEvent() { - const eventData: EventData = { - date: state.date.toString(), - description: state.description, - id: zustand.getState().events.slice(-1)[0].id + 1, - tasks: {}, - volunteers: {}, + // 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 = zustand((state) => state.tasks); + + // get the available tasks + useEffect(() => { + (async () => { + const result = await apiCall<{ text: string; disabled: boolean }[]>( + "GET", + "tasks", + ); + + if (result.ok) { + const tasks = await result.json(); + + zustand.setState(() => ({ + tasks, + })); + } + })(); + }, []); + + // 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), }; - // add all the tasks - state.tasks.forEach((task) => { - eventData.tasks[task] = undefined; - }); + const result = await apiCall("POST", "events", undefined, data); - zustand.getState().addEvent(eventData); + if (result.ok) { + zustand.getState().setEvents(await result.json()); + + props.onOpenChange(false); + } } + // reset the state when the modal gets closed + useEffect(() => { + if (!props.isOpen) { + dispatchState({ action: "reset" }); + } + }, [props.isOpen]); + return ( - - -

Add Event

-
- - (!!dt ? setState({ ...state, date: dt }) : null)} - /> -