added event-creation

This commit is contained in:
z1glr
2025-01-11 02:44:13 +00:00
parent 45f600268f
commit 1b526fcd45
9 changed files with 242 additions and 266 deletions

View File

@@ -1,19 +1,13 @@
package db package db
import ( import (
"database/sql"
"fmt"
"reflect"
"strings"
"time" "time"
"github.com/go-sql-driver/mysql" "github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
_config "github.com/johannesbuehl/golunteer/backend/pkg/config" _config "github.com/johannesbuehl/golunteer/backend/pkg/config"
_logger "github.com/johannesbuehl/golunteer/backend/pkg/logger"
) )
var logger = _logger.Logger
var config = _config.Config var config = _config.Config
// connection to database // connection to database
@@ -37,189 +31,3 @@ func init() {
DB.SetConnMaxLifetime(time.Minute) 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
}

View File

@@ -13,7 +13,7 @@ type EventWithAssignment struct {
type eventDataDB struct { type eventDataDB struct {
Id int `db:"id" json:"id"` 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"` 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) { func All() ([]eventDataDB, error) {
var dbRows []eventDataDB var dbRows []eventDataDB

View File

@@ -15,8 +15,8 @@ type tasksDB struct {
} }
type Task struct { type Task struct {
Text string Text string `json:"text"`
Disabled bool Disabled bool `json:"disabled"`
} }
var c *cache.Cache var c *cache.Cache

View File

@@ -5,6 +5,43 @@ import (
"github.com/johannesbuehl/golunteer/backend/pkg/db/events" "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 { func getEventsAssignments(args HandlerArgs) responseMessage {
response := responseMessage{} response := responseMessage{}

View File

@@ -74,8 +74,12 @@ func init() {
// map with the individual registered endpoints // map with the individual registered endpoints
endpoints := map[string]map[string]func(HandlerArgs) responseMessage{ endpoints := map[string]map[string]func(HandlerArgs) responseMessage{
"GET": {"events/assignments": getEventsAssignments, "events/user/pending": getEventsUserPending}, "GET": {
"POST": {}, "events/assignments": getEventsAssignments,
"events/user/pending": getEventsUserPending,
"tasks": getTasks,
},
"POST": {"events": postEvent},
"PATCH": {}, "PATCH": {},
"DELETE": {}, "DELETE": {},
} }

View File

@@ -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
}

View File

@@ -6,14 +6,6 @@ import { persist } from "zustand/middleware";
export type Task = string; export type Task = string;
export const Tasks: Task[] = [
"Audio",
"Livestream",
"Camera",
"Light",
"Stream Audio",
];
export type Availability = string; export type Availability = string;
export const Availabilities: Availability[] = ["yes", "maybe", "no"]; export const Availabilities: Availability[] = ["yes", "maybe", "no"];
@@ -21,7 +13,7 @@ export const Availabilities: Availability[] = ["yes", "maybe", "no"];
export interface EventData { export interface EventData {
id: number; id: number;
date: string; date: string;
tasks: Partial<Record<Task, string | undefined>>; tasks: Partial<Record<Task, string | null>>;
description: string; description: string;
} }
@@ -32,6 +24,7 @@ interface Zustand {
userName: string; userName: string;
admin: boolean; admin: boolean;
} | null; } | null;
tasks?: Record<number, { text: string; disabled: boolean }>;
setEvents: (events: EventData[]) => void; setEvents: (events: EventData[]) => void;
reset: (zustand?: Partial<Zustand>) => void; reset: (zustand?: Partial<Zustand>) => void;
setPendingEvents: (c: number) => void; setPendingEvents: (c: number) => void;

View File

@@ -35,7 +35,9 @@ export default function Events() {
<div className="flex flex-wrap justify-center gap-4"> <div className="flex flex-wrap justify-center gap-4">
{events.map((ee, ii) => ( {events.map((ee, ii) => (
<Event key={ii} event={ee}> <Event key={ii} event={ee}>
<AssignmentTable tasks={ee.tasks} /> <div className="mt-auto">
<AssignmentTable tasks={ee.tasks} />
</div>
</Event> </Event>
))} ))}
</div> </div>

View File

@@ -1,19 +1,22 @@
import { useState } from "react"; import { useEffect, useReducer } from "react";
import { Add } from "@carbon/icons-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 { getLocalTimeZone, now, ZonedDateTime } from "@internationalized/date";
import { import {
Button, Button,
Checkbox, Checkbox,
CheckboxGroup, CheckboxGroup,
DatePicker, DatePicker,
Form,
Modal, Modal,
ModalBody, ModalBody,
ModalContent, ModalContent,
ModalFooter, ModalFooter,
ModalHeader, ModalHeader,
Spinner,
Textarea, Textarea,
} from "@nextui-org/react"; } from "@nextui-org/react";
import { apiCall } from "@/lib";
interface state { interface state {
date: ZonedDateTime; date: ZonedDateTime;
@@ -21,34 +24,76 @@ interface state {
tasks: Task[]; tasks: Task[];
} }
interface dispatchAction {
action: "set" | "reset";
value?: Partial<state>;
}
export default function AddEvent(props: { export default function AddEvent(props: {
className?: string; className?: string;
isOpen: boolean; isOpen: boolean;
onOpenChange: (isOpen: boolean) => void; onOpenChange: (isOpen: boolean) => void;
}) { }) {
const [state, setState] = useState<state>({ // initial state for the inputs
const initialState: state = {
date: now(getLocalTimeZone()), date: now(getLocalTimeZone()),
description: "", description: "",
tasks: [], tasks: [],
}); };
function addEvent() { // handle state dispatches
const eventData: EventData = { function reducer(state: state, action: dispatchAction): state {
date: state.date.toString(), if (action.action === "reset") {
description: state.description, return initialState;
id: zustand.getState().events.slice(-1)[0].id + 1, } else {
tasks: {}, return { ...state, ...action.value };
volunteers: {}, }
}
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 const result = await apiCall("POST", "events", undefined, data);
state.tasks.forEach((task) => {
eventData.tasks[task] = undefined;
});
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 ( return (
<Modal <Modal
isOpen={props.isOpen} isOpen={props.isOpen}
@@ -59,49 +104,77 @@ export default function AddEvent(props: {
base: "bg-accent-5 ", base: "bg-accent-5 ",
}} }}
> >
<ModalContent> <Form
<ModalHeader> validationBehavior="native"
<h1 className="text-2xl">Add Event</h1> onSubmit={(e) => {
</ModalHeader> e.preventDefault();
<ModalBody> void addEvent();
<DatePicker }}
label="Event date" >
variant="bordered" <ModalContent>
hideTimeZone <ModalHeader>
granularity="minute" <h1 className="text-center text-2xl">Add Event</h1>
value={state.date} </ModalHeader>
onChange={(dt) => (!!dt ? setState({ ...state, date: dt }) : null)}
/> <ModalBody>
<Textarea <DatePicker
variant="bordered" isRequired
placeholder="Description" label="Event date"
value={state.description} name="date"
onValueChange={(desc) => setState({ ...state, description: desc })} variant="bordered"
/> hideTimeZone
<CheckboxGroup granularity="minute"
value={state.tasks} value={state.date}
onValueChange={(newTasks) => onChange={(dt) =>
setState({ ...state, tasks: newTasks }) !!dt
} ? dispatchState({ action: "set", value: { date: dt } })
> : null
{Tasks.map((task, ii) => ( }
<div key={ii}> />
<Checkbox value={task}>{task}</Checkbox> <Textarea
</div> variant="bordered"
))} placeholder="Description"
</CheckboxGroup> name="description"
</ModalBody> value={state.description}
<ModalFooter> onValueChange={(s) =>
<Button dispatchState({ action: "set", value: { description: s } })
color="primary" }
radius="full" />
startContent={<Add size={32} />} <CheckboxGroup
onPress={addEvent} value={state.tasks}
> name="tasks"
Add onValueChange={(s) =>
</Button> dispatchState({ action: "set", value: { tasks: s } })
</ModalFooter> }
</ModalContent> validate={(value) =>
value.length > 0 ? true : "Atleast one task must be selected"
}
>
{tasks !== undefined ? (
Object.entries(tasks)
.filter(([, task]) => !task.disabled)
.map(([id, task]) => (
<div key={id}>
<Checkbox value={id}>{task.text}</Checkbox>
</div>
))
) : (
<Spinner label="Loading" />
)}
</CheckboxGroup>
</ModalBody>
<ModalFooter>
<Button
color="primary"
radius="full"
startContent={<Add size={32} />}
type="submit"
>
Add
</Button>
</ModalFooter>
</ModalContent>
</Form>
</Modal> </Modal>
); );
} }