From fe035c3dc3fb5d5d5449995bd312ea4f6156fdb8 Mon Sep 17 00:00:00 2001 From: mvvasilev Date: Thu, 11 Jan 2024 12:52:41 +0200 Subject: [PATCH] Fully operational --- Cargo.toml | 2 +- frontend/index.html | 4 +- frontend/package.json | 3 +- frontend/src/App.tsx | 2 + frontend/src/assets/calendar-color-icon.png | Bin 0 -> 3808 bytes .../src/components/AvailabilityPicker.tsx | 261 +++++------------- .../src/components/AvailabilityPickerDay.tsx | 93 +++++++ .../src/components/AvailabilityPickerHour.tsx | 86 ++++++ .../src/components/DisableableTooltip.tsx | 28 ++ frontend/src/components/Footer.tsx | 2 +- .../src/components/css/AvailabilityPicker.css | 76 ++++- frontend/src/pages/ExistingEventPage.tsx | 207 ++++++++++++-- frontend/src/pages/NewEventPage.tsx | 51 +++- frontend/src/pages/RootLayout.tsx | 56 +++- frontend/src/pages/ThankYouPage.tsx | 23 ++ frontend/src/types/Availabilities.tsx | 21 ++ frontend/src/utils.ts | 62 ++++- frontend/tsconfig.json | 1 + frontend/yarn.lock | 12 + src/api.rs | 24 +- src/endpoints.rs | 63 +++-- src/main.rs | 2 +- 22 files changed, 798 insertions(+), 281 deletions(-) create mode 100644 frontend/src/assets/calendar-color-icon.png create mode 100644 frontend/src/components/AvailabilityPickerDay.tsx create mode 100644 frontend/src/components/AvailabilityPickerHour.tsx create mode 100644 frontend/src/components/DisableableTooltip.tsx create mode 100644 frontend/src/pages/ThankYouPage.tsx create mode 100644 frontend/src/types/Availabilities.tsx diff --git a/Cargo.toml b/Cargo.toml index dd193d7..2bb34e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -axum = { version = "0.7.3", features = ["macros"] } +axum = { version = "0.7.3", features = ["macros", "tokio"] } chrono = { version = "0.4.31", features = ["serde"] } dotenv = "0.15.0" futures = "0.3.30" diff --git a/frontend/index.html b/frontend/index.html index e4b78ea..edf58be 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ - - Vite + React + TS + findtheti.me +
diff --git a/frontend/package.json b/frontend/package.json index 6c1b124..0290930 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "frontend", + "name": "findtheti.me", "private": true, "version": "0.0.0", "type": "module", @@ -21,6 +21,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-github-corner": "^2.5.0", + "react-hot-toast": "^2.4.1", "react-router-dom": "^6.21.1" }, "devDependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 44864c4..3e6d8c6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import './App.css' import RootLayout from './pages/RootLayout' import NewEventPage from './pages/NewEventPage' import ExistingEventPage from './pages/ExistingEventPage' +import ThankYouPage from './pages/ThankYouPage' function App() { return ( @@ -10,6 +11,7 @@ function App() { } /> } /> + } /> ) diff --git a/frontend/src/assets/calendar-color-icon.png b/frontend/src/assets/calendar-color-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4f29e66c26c49d6865abcb49e1e56d64ee6aee2a GIT binary patch literal 3808 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 literal 0 HcmV?d00001 diff --git a/frontend/src/components/AvailabilityPicker.tsx b/frontend/src/components/AvailabilityPicker.tsx index bf9b3f6..ebee96a 100644 --- a/frontend/src/components/AvailabilityPicker.tsx +++ b/frontend/src/components/AvailabilityPicker.tsx @@ -1,28 +1,19 @@ -import { Box, Card, Divider, Stack, Typography } from "@mui/material"; -import dayjs, { Dayjs } from "dayjs"; -import { MouseEvent, useEffect, useState } from "react"; -import * as utc from 'dayjs/plugin/utc'; -import * as timezone from 'dayjs/plugin/timezone'; -import * as localizedFormat from 'dayjs/plugin/localizedFormat'; -import utils from "../utils"; -import { EventTypes } from "../types/Event"; +import { Box, Stack } from "@mui/material"; +import { MouseEvent, useState } from "react"; import "./css/AvailabilityPicker.css"; -import classNames from 'classnames'; -// import { alpha } from '@material-ui/core/styles/colorManipulator'; +import { AvailabilityDay, OthersDay, OthersDays } from "../types/Availabilities"; +import utils from "../utils"; +import AvailabilityPickerDay from "./AvailabilityPickerDay"; +import dayjs, { Dayjs } from "dayjs"; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; dayjs.extend(utc) dayjs.extend(timezone) dayjs.extend(localizedFormat) -type AvailabilityTime = { - fromTime: Dayjs, - toTime: Dayjs -} - -type AvailabilityDay = { - forDate: Dayjs, - availableTimes: AvailabilityTime[] -} +const HALFHOUR_DISPLAY_HEIGHT: number = 15; type GhostPreviewProps = { top: number, @@ -31,173 +22,19 @@ type GhostPreviewProps = { height: number } -const HALFHOUR_DISPLAY_HEIGHT: number = 15; - -const Hour = (props: { - dateTime: Dayjs, - isFullHourSelected: boolean, - isHalfHourSelected: boolean, - onMouseEnterHalfhour: (e: MouseEvent, time: Dayjs) => void, - onMouseClickOnHalfhour: (time: Dayjs) => void -}) => { - let isEvenHour = props.dateTime.hour() % 2 == 0; - - return ( - - props.onMouseEnterHalfhour(e, props.dateTime)} - onClick={(_) => props.onMouseClickOnHalfhour(props.dateTime)} - > - - { utils.formatTimeFromHourOfDay(props.dateTime.hour(), 0) } - - - props.onMouseEnterHalfhour(e, props.dateTime.add(30, "minutes"))} - onClick={(_) => props.onMouseClickOnHalfhour(props.dateTime.add(30, "minutes"))} - /> - - ); -} - -const isSelectedAvailability = (day: AvailabilityDay, time: Dayjs): boolean => { - return day.availableTimes.some(t => t.fromTime.unix() <= time.unix() && time.unix() <= t.toTime.unix()); -} - -const Day = (props: { - day: AvailabilityDay, - eventType: String, - onMouseEnterHalfhour: (e: MouseEvent, time: dayjs.Dayjs) => void, - onMouseClickHalfhour: (day: AvailabilityDay, time: dayjs.Dayjs) => void -}) => { - - const generateHours = (): JSX.Element[] => { - let hours: JSX.Element[] = []; - - for (var i = 0; i < 24; i++) { - let time = props.day.forDate.set("hour", i); - hours.push( - , time: dayjs.Dayjs): void => { - props.onMouseEnterHalfhour(e, time); - }} - onMouseClickOnHalfhour={(time: dayjs.Dayjs): void => { - props.onMouseClickHalfhour(props.day, time); - }} - /> - ); - } - - return hours; - } - - return ( - - - { - (props.eventType === EventTypes.WEEK) && - - { props.day.forDate.format("dddd") } - - } - { - (props.eventType === EventTypes.DAY) && - - Any Day - - } - { - (props.eventType === EventTypes.DATE_RANGE || props.eventType === EventTypes.SPECIFIC_DATE) && - - { props.day.forDate.format("LL") } - - } - - - - - {generateHours()} - - - ); -} - const AvailabilityPicker = (props: { - fromDate: Dayjs, - toDate: Dayjs, + days: AvailabilityDay[], + setDays: (days: AvailabilityDay[]) => void, + othersAvailabilities: OthersDays[], eventType: String, - availabilityDurationInMinutes: number + availabilityDurationInMinutes: number, }) => { - - const [days, setDays] = useState([]); + const [ghostPreviewProps, setGhostPreviewProps] = useState(); - useEffect(() => { - let localTimezone = dayjs.tz.guess(); - - let localFromDate = props.fromDate.tz(localTimezone); - let localToDate = props.toDate.tz(localTimezone); - - switch (props.eventType) { - case EventTypes.SPECIFIC_DATE: { - createAvailabilitiesBasedOnInitialDate(localFromDate, 1); - break; - } - case EventTypes.DATE_RANGE: { - createAvailabilitiesBasedOnInitialDate(localFromDate, Math.abs(localFromDate.diff(localToDate, "day", false))); - break; - } - case EventTypes.DAY: { - createAvailabilitiesBasedOnUnspecifiedInitialDate(1, localTimezone); - break; - } - case EventTypes.WEEK: { - createAvailabilitiesBasedOnUnspecifiedInitialDate(7, localTimezone); - break; - } - } - }, [props]); - - const createAvailabilitiesBasedOnUnspecifiedInitialDate = (numberOfDays: number, tz: string) => { - createAvailabilitiesBasedOnInitialDate(dayjs.tz("1970-01-05 00:00:00", tz), numberOfDays); - } - - const createAvailabilitiesBasedOnInitialDate = (date: Dayjs, numberOfDays: number) => { - let availabilities: AvailabilityDay[] = []; - - for (var i: number = 0; i < numberOfDays; i++) { - let availability: AvailabilityDay = { - forDate: date.add(i, "day").startOf("day"), - availableTimes: [] - } - - availabilities.push(availability); - } - - setDays(availabilities); - } - const displayGhostPeriod = (e: MouseEvent, time: Dayjs) => { - let timeInMinutes = (time.hour() * 60) + time.minute(); - let timeLeftInDayLessThanDuration = (timeInMinutes + props.availabilityDurationInMinutes) > 24 * 60; + let timeInMinutes = (time.hour() * 60.0) + time.minute(); + let timeLeftInDayLessThanDuration = (timeInMinutes + props.availabilityDurationInMinutes) > 24.0 * 60.0; if (timeLeftInDayLessThanDuration) { return; @@ -215,16 +52,34 @@ const AvailabilityPicker = (props: { // @ts-ignore left: e.target?.offsetLeft - scrollLeft, width: element.width, - height: (props.availabilityDurationInMinutes/60) * 2 * HALFHOUR_DISPLAY_HEIGHT + height: (props.availabilityDurationInMinutes/60.0) * 2 * HALFHOUR_DISPLAY_HEIGHT }) } - const createAvailability = (day: AvailabilityDay, time: Dayjs) => { + const deleteAvailability = (day: AvailabilityDay, time: Dayjs) => { + let existingTime = day.availableTimes.findIndex(t => utils.dayjsIsBetweenUnixExclusive(t.fromTime, time, t.toTime)); + + if (existingTime >= 0) { + console.log(`delete ${existingTime} from`, day) + + day.availableTimes.splice(existingTime, 1); + + props.setDays([...props.days]); + } + } + + const changeAvailability = (day: AvailabilityDay, time: Dayjs, isDelete: boolean) => { + if (isDelete) { + deleteAvailability(day, time); + + return; + } + let fromTime = time; let toTime = time.add(props.availabilityDurationInMinutes, "minutes"); - let existingTimeContainingFrom = day.availableTimes.findIndex(t => (t.fromTime.isBefore(fromTime) || t.fromTime.isSame(fromTime)) && (t.toTime.isAfter(fromTime) || t.toTime.isSame(fromTime))); - let existingTimeContainingTo = day.availableTimes.findIndex(t => (t.fromTime.isBefore(toTime) || t.fromTime.isSame(toTime)) && (t.toTime.isAfter(toTime) || t.toTime.isSame(toTime))); + let existingTimeContainingFrom = day.availableTimes.findIndex(t => utils.dayjsIsBetweenUnixInclusive(t.fromTime, fromTime, t.toTime)); + let existingTimeContainingTo = day.availableTimes.findIndex(t => utils.dayjsIsBetweenUnixInclusive(t.fromTime, toTime, t.toTime)); // the newly created availability crosses another single one. Both have the same from and to. Do nothing. if (existingTimeContainingFrom >= 0 && existingTimeContainingTo >= 0 && existingTimeContainingFrom === existingTimeContainingTo) { @@ -234,16 +89,18 @@ const AvailabilityPicker = (props: { // the newly created availability crosses 2 existing ones. Combine all of them into a single one. if (existingTimeContainingFrom >= 0 && existingTimeContainingTo >= 0 && existingTimeContainingFrom !== existingTimeContainingTo) { let newFrom = day.availableTimes[existingTimeContainingFrom].fromTime; - let newTo = day.availableTimes[existingTimeContainingFrom].toTime; + let newTo = day.availableTimes[existingTimeContainingTo].toTime; - day.availableTimes.splice(existingTimeContainingFrom); - day.availableTimes.splice(existingTimeContainingTo); + day.availableTimes.splice(existingTimeContainingFrom, 1); + day.availableTimes.splice(existingTimeContainingTo, 1); day.availableTimes.push({ fromTime: newFrom, toTime: newTo }); + props.setDays([...props.days]); + return; } @@ -251,27 +108,31 @@ const AvailabilityPicker = (props: { if (existingTimeContainingFrom >= 0 && existingTimeContainingTo < 0) { let newFrom = day.availableTimes[existingTimeContainingFrom].fromTime; - day.availableTimes.splice(existingTimeContainingFrom); + day.availableTimes.splice(existingTimeContainingFrom, 1); day.availableTimes.push({ fromTime: newFrom, toTime: toTime }); + props.setDays([...props.days]); + return; } // The newly created availability to is within an existing one. Combine the 2 into one. - if (existingTimeContainingFrom >= 0 && existingTimeContainingTo < 0) { - let newTo = day.availableTimes[existingTimeContainingFrom].toTime; + if (existingTimeContainingFrom < 0 && existingTimeContainingTo >= 0) { + let newTo = day.availableTimes[existingTimeContainingTo].toTime; - day.availableTimes.splice(existingTimeContainingFrom); + day.availableTimes.splice(existingTimeContainingTo, 1); day.availableTimes.push({ fromTime: fromTime, toTime: newTo }); + props.setDays([...props.days]); + return; } @@ -280,12 +141,13 @@ const AvailabilityPicker = (props: { toTime: toTime }); - setDays([...days]) + props.setDays([...props.days]); } return ( e.preventDefault()} > setGhostPreviewProps(null)} > { - days.map(day => - + { + return { + userName: a.userName, + availableTimes: a.days.find(d => d.forDate.unix() === day.forDate.unix())?.availableTimes ?? [] + } as OthersDay; + })} onMouseEnterHalfhour={(e: MouseEvent, time: dayjs.Dayjs) => { displayGhostPeriod(e, time); }} - onMouseClickHalfhour={(day: AvailabilityDay, time: dayjs.Dayjs) => { - createAvailability(day, time); + onMouseClickHalfhour={(day: AvailabilityDay, time: dayjs.Dayjs, isDelete: boolean) => { + changeAvailability(day, time, isDelete); }} /> ) diff --git a/frontend/src/components/AvailabilityPickerDay.tsx b/frontend/src/components/AvailabilityPickerDay.tsx new file mode 100644 index 0000000..0bc9eae --- /dev/null +++ b/frontend/src/components/AvailabilityPickerDay.tsx @@ -0,0 +1,93 @@ +import { AvailabilityDay, OthersDay } from "../types/Availabilities"; +import utils from "../utils"; +import { Box, Card, Divider, Typography } from "@mui/material"; +import { EventTypes } from "../types/Event"; +import AvailabilityPickerHour from "./AvailabilityPickerHour"; +import "./css/AvailabilityPicker.css"; +import dayjs from "dayjs"; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; + +dayjs.extend(utc) +dayjs.extend(timezone) +dayjs.extend(localizedFormat) + +const AvailabilityPickerDay = (props: { + day: AvailabilityDay, + eventType: String, + halfHourDisplayHeight: number, + othersAvailabilityDay: OthersDay[], + onMouseEnterHalfhour: (e: React.MouseEvent, time: dayjs.Dayjs) => void, + onMouseClickHalfhour: (day: AvailabilityDay, time: dayjs.Dayjs, isDelete: boolean) => void +}) => { + + const generateHours = (): JSX.Element[] => { + let hours: JSX.Element[] = []; + + for (var i = 0; i < 24; i++) { + let fullHourTime = props.day.forDate.set("hour", i); + let halfHourTime = fullHourTime.add(30, "minutes"); + + hours.push( + d.availableTimes.some(t => utils.dayjsIsBetweenUnixExclusive(t.fromTime, fullHourTime, t.toTime))).map(d => d.userName)} + namesMarkedHalfHourAsAvailable={props.othersAvailabilityDay.filter(d => d.availableTimes.some(t => utils.dayjsIsBetweenUnixExclusive(t.fromTime, halfHourTime, t.toTime))).map(d => d.userName)} + isFullHourSelected={props.day.availableTimes.some(a => utils.dayjsIsBetweenUnixExclusive(a.fromTime, fullHourTime, a.toTime))} + isHalfHourSelected={props.day.availableTimes.some(a => utils.dayjsIsBetweenUnixExclusive(a.fromTime, halfHourTime, a.toTime))} + onMouseEnterHalfhour={(e: React.MouseEvent, time: dayjs.Dayjs): void => { + props.onMouseEnterHalfhour(e, time); + }} + onMouseClickOnHalfhour={(time: dayjs.Dayjs, isDelete: boolean): void => { + props.onMouseClickHalfhour(props.day, time, isDelete); + }} + /> + ); + } + + return hours; + } + + return ( + + + { + (props.eventType === EventTypes.WEEK) && + + { props.day.forDate.format("dddd") } + + } + { + (props.eventType === EventTypes.DAY) && + + Any Day + + } + { + (props.eventType === EventTypes.DATE_RANGE || props.eventType === EventTypes.SPECIFIC_DATE) && + + { props.day.forDate.format("LL") } + + } + + + + + {generateHours()} + + + ); +} + +export default AvailabilityPickerDay; \ No newline at end of file diff --git a/frontend/src/components/AvailabilityPickerHour.tsx b/frontend/src/components/AvailabilityPickerHour.tsx new file mode 100644 index 0000000..28a1135 --- /dev/null +++ b/frontend/src/components/AvailabilityPickerHour.tsx @@ -0,0 +1,86 @@ +import { Box, Typography } from "@mui/material"; +import classNames from "classnames"; +import utils from "../utils"; +import "./css/AvailabilityPicker.css"; +import dayjs, { Dayjs } from "dayjs"; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; +import DisableableTooltip from "./DisableableTooltip"; + +dayjs.extend(utc) +dayjs.extend(timezone) +dayjs.extend(localizedFormat) + +const AvailabilityPickerHour = (props: { + dateTime: Dayjs, + isFullHourSelected: boolean, + isHalfHourSelected: boolean, + halfHourDisplayHeight: number, + namesMarkedFullHourAsAvailable: String[], + namesMarkedHalfHourAsAvailable: String[], + onMouseEnterHalfhour: (e: React.MouseEvent, time: Dayjs) => void, + onMouseClickOnHalfhour: (time: Dayjs, isDelete: boolean) => void +}) => { + const generateTooltipText = (names: String[]): String => { + return `${names.length} ${names.length > 1 ? "people have" : "person has"} marked this time as available: ${names.join(", ")}`; + } + + return ( + + + props.onMouseEnterHalfhour(e, props.dateTime)} + onMouseDown={(e) => { + if (e.button !== 0 && e.button !== 2) { + return; + } + + props.onMouseClickOnHalfhour(props.dateTime, e.button === 2); + }} + > + + { utils.formatTimeFromHourOfDay(props.dateTime.hour(), 0) } + + + + + props.onMouseEnterHalfhour(e, props.dateTime.add(30, "minutes"))} + onMouseDown={(e) => { + if (e.button !== 0 && e.button !== 2) { + return; + } + + props.onMouseClickOnHalfhour(props.dateTime.add(30, "minutes"), e.button === 2); + }} + /> + + + ); +} + +export default AvailabilityPickerHour; \ No newline at end of file diff --git a/frontend/src/components/DisableableTooltip.tsx b/frontend/src/components/DisableableTooltip.tsx new file mode 100644 index 0000000..69e8bdb --- /dev/null +++ b/frontend/src/components/DisableableTooltip.tsx @@ -0,0 +1,28 @@ +import { useState, useEffect } from 'react' + +import { Tooltip, TooltipProps } from '@mui/material' + +const DisableableTooltip = ({ + disabled, + children, + ...tooltipProps +}: TooltipProps & { disabled: boolean }) => { + const [open, setOpen] = useState(false) + + useEffect(() => { + if (disabled) setOpen(false) + }, [disabled]) + + return ( + !disabled && setOpen(true)} + onClose={() => setOpen(false)} + > + {children} + + ) +} + +export default DisableableTooltip; \ No newline at end of file diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 47e7dff..4dbe604 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -7,7 +7,7 @@ export default function Footer() { direction="column" justifyContent="center" > - + Created by mvvasilev | Github diff --git a/frontend/src/components/css/AvailabilityPicker.css b/frontend/src/components/css/AvailabilityPicker.css index f108096..e6a92e2 100644 --- a/frontend/src/components/css/AvailabilityPicker.css +++ b/frontend/src/components/css/AvailabilityPicker.css @@ -1,24 +1,74 @@ :root { - --hover-color: #004455; - --hour-light-color: #003344; - --hour-dark-color: #002233; + --hover-color: #555555; + --hour-light-color: #333333; + --hour-dark-color: #222222; --hour-border-bolor: #777; --hour-text-color: #ddd; --active-color: #223300; - --currently-selected-color: #112200; + --currently-selected-color: #336622; + --currently-selected-color-hover: #338822; --halfhour-border-color: #333; + --halfhour-border-color-light: #777; + --ghost-box-color: #118811; + --time-text-color: #ddd; + --hour-marked-available: #330033; + +} + +div[class*=" hour-available-"]{ + background-color: #bb00bb; +} + +div.hour-available-0 { + background-color: inherit; +} + +div.hour-available-1 { + background-color: #330033; +} + +div.hour-available-2 { + background-color: #440044; +} + +div.hour-available-3 { + background-color: #550055; +} + +div.hour-available-4 { + background-color: #660066; +} + +div.hour-available-5 { + background-color: #770077; +} + +div.hour-available-6 { + background-color: #880088; +} + +div.hour-available-7 { + background-color: #990099; +} + +div.hour-available-8 { + background-color: #aa00aa; +} + +div.hour-available-9 { + background-color: #bb00bb; } div.hour-light { width: 100%; - border-bottom: solid 1px; + border-top: solid 1px; border-color: var(--hour-border-bolor); background-color: var(--hour-light-color); } div.hour-dark { width: 100%; - border-bottom: solid 1px; + border-top: solid 1px; border-color: var(--hour-border-bolor); background-color: var(--hour-dark-color); } @@ -26,7 +76,7 @@ div.hour-dark { div.full-hour { width: 100%; border-bottom: dashed 1px; - border-color: var(--halfhour-border-color) + border-color: var(--halfhour-border-color-light) } div.half-hour { @@ -45,12 +95,16 @@ div.full-hour:active, div.half-hour:active { background-color: var(--active-color); } +div.selected-availability:hover { + background-color: var(--currently-selected-color-hover); +} + div.ghost-box { position: absolute; - background-color: rgba(0, 255, 0, 0.1); + background-color: rgba(0, 255, 0, 0.2); border: solid 1px; - border-color: #227722; - border-radius: 1; + border-color: var(--ghost-box-color); + border-radius: 4%; margin: 0; padding: 0; pointer-events: none; @@ -59,7 +113,7 @@ div.ghost-box { p.time-text { text-align: left; font-size: 0.65em; - color: #ddd; + color: var(--time-text-color); } div.day-card { diff --git a/frontend/src/pages/ExistingEventPage.tsx b/frontend/src/pages/ExistingEventPage.tsx index 26310a2..e19a47e 100644 --- a/frontend/src/pages/ExistingEventPage.tsx +++ b/frontend/src/pages/ExistingEventPage.tsx @@ -1,31 +1,180 @@ import { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; -import { Event, createEvent } from '../types/Event'; +import { useNavigate, useParams } from "react-router-dom"; +import { Event, EventTypes, createEvent } from '../types/Event'; import Grid from '@mui/material/Unstable_Grid2' import { Button, TextField, Typography } from "@mui/material"; import AvailabilityPicker from "../components/AvailabilityPicker"; -import dayjs from "dayjs"; +import dayjs, { Dayjs } from "dayjs"; import utils from "../utils"; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; +import duration from 'dayjs/plugin/duration'; +import { AvailabilityDay, AvailabilityTime, OthersDays } from "../types/Availabilities"; +import toast from "react-hot-toast"; + +dayjs.extend(utc) +dayjs.extend(timezone) +dayjs.extend(localizedFormat) +dayjs.extend(duration); export default function ExistingEventPage() { + const navigate = useNavigate(); + let { eventId } = useParams(); + const [canSubmit, setCanSubmit] = useState(false); const [event, setEvent] = useState(createEvent()); + const [days, setDays] = useState([]); + const [othersDays, setOthersDays] = useState([]); + const [userName, setUserName] = useState(undefined); useEffect(() => { - fetch(`/api/events/${eventId}`) - .then(resp => resp.json()) - .then(resp => setEvent({ - name: resp.result?.name, - description: resp.result?.description, - fromDate: dayjs.utc(resp.result?.from_date), - toDate: dayjs.utc(resp.result?.to_date), - eventType: resp.result?.event_type, - snowflakeId: resp.result?.snowflake_id, - duration: resp.result?.duration - })); + utils.showSpinner(); + + Promise.all([ + utils.performRequest(`/api/events/${eventId}`) + .then(result => setEvent({ + name: result?.name, + description: result?.description, + fromDate: dayjs.utc(result?.from_date), + toDate: dayjs.utc(result?.to_date), + eventType: result?.event_type, + snowflakeId: result?.snowflake_id, + duration: result?.duration + })) + .catch(e => toast.error(e)), + + utils.performRequest(`/api/events/${eventId}/availabilities`) + .then((result: [{ id: number, from_date: string, to_date: string, user_name: string }]) => { + let othersDays: OthersDays[] = []; + let localTimezone = dayjs.tz.guess(); + + for (const availability of result) { + let fromDate = dayjs(availability.from_date).tz(localTimezone); + let toDate = dayjs(availability.to_date).tz(localTimezone); + + var userTimes = othersDays.find(d => d.userName === availability.user_name); + + if (!userTimes) { + userTimes = { userName: availability.user_name, days: []} as OthersDays; + + othersDays.push(userTimes); + } + + let availabilityDay = fromDate.hour(0).minute(0).second(0).millisecond(0); + + var day = userTimes.days.find(d => d.forDate.unix() === availabilityDay.unix()); + + if (!day) { + day = { + forDate: availabilityDay, + availableTimes: [] + } as AvailabilityDay; + + userTimes.days.push(day); + } + + day.availableTimes.push({ + fromTime: fromDate, + toTime: toDate + } as AvailabilityTime) + } + + setOthersDays(othersDays); + }) + .catch(e => toast.error(e)) + ]) + .finally(() => utils.hideSpinner());; + }, [eventId]); + useEffect(() => { + document.title = `findtheti.me - ${event.name}`; + }, [event]) + + useEffect(() => { + if (event.fromDate === null || event.toDate === null || event.eventType === null) { + return; + } + + let localTimezone = dayjs.tz.guess(); + + let localFromDate = event.fromDate.tz(localTimezone); + let localToDate = event.toDate.tz(localTimezone); + + switch (event.eventType) { + case EventTypes.SPECIFIC_DATE: { + createAvailabilitiesBasedOnInitialDate(localFromDate, 1); + break; + } + case EventTypes.DATE_RANGE: { + createAvailabilitiesBasedOnInitialDate(localFromDate, Math.abs(localFromDate.diff(localToDate, "day", false))); + break; + } + case EventTypes.DAY: { + createAvailabilitiesBasedOnUnspecifiedInitialDate(1, localTimezone); + break; + } + case EventTypes.WEEK: { + createAvailabilitiesBasedOnUnspecifiedInitialDate(7, localTimezone); + break; + } + } + }, [event]); + + useEffect(() => { + var valid = !utils.isNullOrUndefined(userName) && userName !== ""; + + valid &&= days.some(day => day.availableTimes.length > 0); + + setCanSubmit(valid); + }, [userName, days]); + + const createAvailabilitiesBasedOnUnspecifiedInitialDate = (numberOfDays: number, tz: string) => { + createAvailabilitiesBasedOnInitialDate(dayjs.tz("1970-01-05 00:00:00", tz), numberOfDays); + } + + const createAvailabilitiesBasedOnInitialDate = (date: Dayjs, numberOfDays: number) => { + let availabilities: AvailabilityDay[] = []; + + for (var i: number = 0; i < numberOfDays; i++) { + let availability: AvailabilityDay = { + forDate: date.add(i, "day").startOf("day"), + availableTimes: [] + } + + availabilities.push(availability); + } + + setDays(availabilities); + } + + const submitAvailabilities = () => { + utils.showSpinner(); + + let body = { + user_name: userName, + availabilities: days.flatMap(day => day.availableTimes.map(a => { + return { + from_date: a.fromTime.utc(), + to_date: a.toTime.utc() + } + })) + }; + + utils.performRequest(`/api/events/${eventId}/availabilities`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(body) + }) + .then(_ => navigate("/thank-you")) + .catch(e => toast.error(e)) + .finally(() => utils.hideSpinner()); + }; + return ( @@ -49,29 +198,41 @@ export default function ExistingEventPage() { { (event.fromDate !== null && event.toDate !== null && event.eventType !== null) && setDays(days)} + othersAvailabilities={othersDays} eventType={event.eventType} availabilityDurationInMinutes={event.duration} /> } + + Left-click to select when you're available, right-click to remove the highlighted hours. + { - // event.description = e.target.value; - // setEvent({...event}); - // }} + value={userName || ""} + onChange={(e) => { + if (e.target.value?.length > 100) { + e.preventDefault(); + return; + } + + setUserName(e.target.value); + }} label="Your Name" /> - diff --git a/frontend/src/pages/NewEventPage.tsx b/frontend/src/pages/NewEventPage.tsx index 3c4dd6c..edb6a33 100644 --- a/frontend/src/pages/NewEventPage.tsx +++ b/frontend/src/pages/NewEventPage.tsx @@ -1,10 +1,12 @@ import { Alert, Button, MenuItem, Select, Slider, TextField, Typography } from '@mui/material'; import Grid from '@mui/material/Unstable_Grid2' -import { DateTimePicker } from '@mui/x-date-pickers'; +import { DatePicker } from '@mui/x-date-pickers'; import { useEffect, useState } from 'react'; import { useNavigate } from "react-router-dom" import { Event, EventTypes, createEvent } from '../types/Event'; import utils from '../utils'; +import toast from 'react-hot-toast'; +import dayjs from 'dayjs'; export default function NewEventPage() { const navigate = useNavigate(); @@ -17,26 +19,42 @@ export default function NewEventPage() { }, [event]) function validateEvent(): void { - console.log(event); var valid: boolean = true; - valid &&= event.name && event.name !== ""; - valid &&= event.eventType !== EventTypes.UNKNOWN || event.eventType !== null; + let today = dayjs().hour(0).minute(0).second(0).millisecond(0); + + valid &&= !utils.isNullOrUndefined(event.name) && event.name !== ""; + + valid &&= event.eventType !== EventTypes.UNKNOWN && event.eventType !== null; if (event.eventType === EventTypes.DATE_RANGE) { - valid &&= event.fromDate !== null; - valid &&= event.toDate !== null; + + valid &&= !utils.isNullOrUndefined(event.fromDate) && !utils.isNullOrUndefined(event.toDate); + + valid &&= !utils.isNullOrUndefined(event.toDate) && event.toDate!.unix() > today.unix(); + + valid &&= !utils.isNullOrUndefined(event.fromDate) && event.fromDate!.unix() >= today.unix(); + + valid &&= !utils.isNullOrUndefined(event.fromDate) && !utils.isNullOrUndefined(event.toDate) && event.toDate!.unix() > event.fromDate!.unix(); + + valid &&= !utils.isNullOrUndefined(event.fromDate) && !utils.isNullOrUndefined(event.toDate) && event.toDate!.diff(event.fromDate!, "days") >= 1; + + valid &&= !utils.isNullOrUndefined(event.fromDate) && !utils.isNullOrUndefined(event.toDate) && event.toDate!.diff(event.fromDate!, "days") <= 14; } if (event.eventType === EventTypes.SPECIFIC_DATE) { - valid &&= event.fromDate !== null; + valid &&= !utils.isNullOrUndefined(event.fromDate); + + valid &&= !utils.isNullOrUndefined(event.fromDate) && event.fromDate!.unix() >= today.unix(); } setEventValid(valid); } function saveEvent() { - fetch("/api/events", { + utils.showSpinner(); + + utils.performRequest("/api/events", { method: "POST", headers: { "Content-Type": "application/json" @@ -50,10 +68,13 @@ export default function NewEventPage() { duration: event.duration }) }) - .then(resp => resp.json()) .then(resp => { - navigate(resp.result.snowflake_id) + navigate(resp.snowflake_id) }) + .catch(err => { + toast.error(err) + }) + .finally(() => utils.hideSpinner()); } return ( @@ -146,8 +167,9 @@ export default function NewEventPage() { { event.eventType == EventTypes.SPECIFIC_DATE && - { event.fromDate = value ?? null; @@ -160,8 +182,9 @@ export default function NewEventPage() { { event.eventType == EventTypes.DATE_RANGE && - { event.fromDate = value ?? null; @@ -174,8 +197,10 @@ export default function NewEventPage() { { event.eventType == EventTypes.DATE_RANGE && - { event.toDate = value ?? null; diff --git a/frontend/src/pages/RootLayout.tsx b/frontend/src/pages/RootLayout.tsx index 01e6a7c..be1db97 100644 --- a/frontend/src/pages/RootLayout.tsx +++ b/frontend/src/pages/RootLayout.tsx @@ -1,5 +1,5 @@ import { ThemeProvider } from "@emotion/react"; -import { CssBaseline, Divider, Paper, createTheme } from "@mui/material"; +import { Backdrop, CircularProgress, CssBaseline, Divider, Paper, createTheme } from "@mui/material"; import Grid from '@mui/material/Unstable_Grid2' import GithubCorner from "react-github-corner"; import Header from "../components/Header"; @@ -7,7 +7,10 @@ import Footer from "../components/Footer"; import { LocalizationProvider } from "@mui/x-date-pickers"; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' import dayjs from "dayjs"; -import * as utc from 'dayjs/plugin/utc'; +import utc from 'dayjs/plugin/utc'; +import { Toaster } from "react-hot-toast"; +import { useEffect, useState } from "react"; +import utils from "../utils"; dayjs.extend(utc); @@ -17,11 +20,39 @@ const theme = createTheme({ } }); -export default function RootLayout(props: { children: React.ReactNode }) { +const RootLayout = (props: { children: React.ReactNode }) => { + + const [spinner, showSpinner] = useState(false); + + useEffect(() => { + window.addEventListener("onSpinnerStatusChange", () => { + showSpinner(utils.isSpinnerShown()); + }); + }, []); + return ( + { + + + + } + + + ); -}; \ No newline at end of file +}; + +export default RootLayout; \ No newline at end of file diff --git a/frontend/src/pages/ThankYouPage.tsx b/frontend/src/pages/ThankYouPage.tsx new file mode 100644 index 0000000..401cace --- /dev/null +++ b/frontend/src/pages/ThankYouPage.tsx @@ -0,0 +1,23 @@ +import { Typography } from '@mui/material'; +import Grid from '@mui/material/Unstable_Grid2' +import { useNavigate } from 'react-router-dom'; + +const ThankYouPage = () => { + const navigate = useNavigate(); + + return ( + + + Thank You! + + + Your response has been recorded. Check in with the event organizer(s) for the exact date and time! + + + To view the available times of all attendees, feel free to navigate(-1)}>navigate back to the event page. + + + ); +} + +export default ThankYouPage; \ No newline at end of file diff --git a/frontend/src/types/Availabilities.tsx b/frontend/src/types/Availabilities.tsx new file mode 100644 index 0000000..1438ea3 --- /dev/null +++ b/frontend/src/types/Availabilities.tsx @@ -0,0 +1,21 @@ +import { Dayjs } from "dayjs" + +export type AvailabilityTime = { + fromTime: Dayjs, + toTime: Dayjs +} + +export type AvailabilityDay = { + forDate: Dayjs, + availableTimes: AvailabilityTime[] +} + +export type OthersDays = { + userName: String, + days: AvailabilityDay[] +} + +export type OthersDay = { + userName: String, + availableTimes: AvailabilityTime[] +} \ No newline at end of file diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index e10c8c9..a1fe5bb 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -1,9 +1,43 @@ -import dayjs from "dayjs"; -import * as duration from 'dayjs/plugin/duration'; - -dayjs.extend(duration) +import dayjs, { Dayjs } from "dayjs"; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; +import duration from 'dayjs/plugin/duration'; +dayjs.extend(utc) +dayjs.extend(timezone) +dayjs.extend(localizedFormat) +dayjs.extend(duration); const utils = { + performRequest: (url: string | URL | Request, options?: RequestInit | undefined): Promise => { + return fetch(url, options).then(async resp => { + if (!resp.ok) { + let errorTextResult = await resp.text(); + + var errorMsg = errorTextResult; + + try { + let jsonResult: any = JSON.parse(errorTextResult); + + errorMsg = jsonResult?.error?.message; + } catch(err) { + errorMsg = errorTextResult; + } + + throw errorMsg; + } else { + let successTextResult = await resp.text(); + + try { + let jsonResult = JSON.parse(successTextResult); + + return jsonResult?.result; + } catch(err) { + return successTextResult; + } + } + }) + }, toHoursAndMinutes: (totalMinutes: number): { hours: number, minutes: number } => { const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; @@ -24,6 +58,26 @@ const utils = { }, formatTimeFromHourOfDay: (hourOfDay: number, minutes: number): String => { return dayjs.duration({ hours: hourOfDay, minutes: minutes }).format('HH:mm'); + }, + dayjsIsBetweenUnixExclusive: (lowerBound: Dayjs, time: Dayjs, upperBound: Dayjs): boolean => { + return lowerBound.unix() <= time.unix() && time.unix() < upperBound.unix() + }, + dayjsIsBetweenUnixInclusive: (lowerBound: Dayjs, time: Dayjs, upperBound: Dayjs): boolean => { + return lowerBound.unix() <= time.unix() && time.unix() <= upperBound.unix() + }, + isSpinnerShown: (): boolean => { + return localStorage.getItem("SpinnerShowing") === "true"; + }, + showSpinner: (): void => { + localStorage.setItem("SpinnerShowing", "true"); + window.dispatchEvent(new Event("onSpinnerStatusChange")); + }, + hideSpinner: (): void => { + localStorage.removeItem("SpinnerShowing"); + window.dispatchEvent(new Event("onSpinnerStatusChange")); + }, + isNullOrUndefined: (thing: any): boolean => { + return thing === null || thing === undefined; } } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index a7fc6fb..c19291e 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -5,6 +5,7 @@ "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, + "esModuleInterop": true, /* Bundler mode */ "moduleResolution": "bundler", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 6924b21..87677a2 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1487,6 +1487,11 @@ globby@^11.1.0: merge2 "^1.4.1" slash "^3.0.0" +goober@^2.1.10: + version "2.1.13" + resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.13.tgz#e3c06d5578486212a76c9eba860cbc3232ff6d7c" + integrity sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ== + graphemer@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz" @@ -1864,6 +1869,13 @@ react-github-corner@^2.5.0: resolved "https://registry.yarnpkg.com/react-github-corner/-/react-github-corner-2.5.0.tgz#e350d0c69f69c075bc0f1d2a6f1df6ee91da31f2" integrity sha512-ofds9l6n61LJc6ML+jSE6W9ZSQvATcMR9evnHPXua1oDYj289HKODnVqFUB/g2a4ieBjDHw416iHP3MjqnU76Q== +react-hot-toast@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.4.1.tgz#df04295eda8a7b12c4f968e54a61c8d36f4c0994" + integrity sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ== + dependencies: + goober "^2.1.10" + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" diff --git a/src/api.rs b/src/api.rs index 5dab7a7..cf621c1 100644 --- a/src/api.rs +++ b/src/api.rs @@ -31,18 +31,26 @@ pub(crate) async fn routes() -> Result { .with_state(AppState::new().await?)) } -pub(crate) fn ok(r: Result) -> UniversalResponseDto { +pub(crate) fn ok(r: Result) -> UniversalResponseDto { match r { Ok(res) => UniversalResponseDto { status: StatusCode::OK, result: Some(res), error: None, }, - Err(err) => internal_server_error(err), + Err(err) => error(err), } } -pub(crate) fn internal_server_error(e: E) -> UniversalResponseDto { +pub(crate) fn error(e: ApplicationError) -> UniversalResponseDto { + UniversalResponseDto { + status: e.status, + result: None, + error: Some(ErrorDto { message: format!("{}", e)}) + } +} + +pub(crate) fn internal_server_error(e: ApplicationError) -> UniversalResponseDto { UniversalResponseDto { status: StatusCode::INTERNAL_SERVER_ERROR, result: None, @@ -104,14 +112,15 @@ impl AppState { } } -#[derive(Debug, Serialize)] +#[derive(Debug)] pub struct ApplicationError { + status: StatusCode, msg: String, } impl ApplicationError { - pub fn new(msg: String) -> Self { - Self { msg } + pub fn new(msg: String, status: StatusCode) -> Self { + Self { msg, status } } } @@ -119,6 +128,7 @@ impl From for ApplicationError { fn from(value: sqlx::Error) -> Self { Self { msg: value.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR } } } @@ -127,6 +137,7 @@ impl From for ApplicationError { fn from(value: MigrateError) -> Self { Self { msg: value.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR } } } @@ -135,6 +146,7 @@ impl From for ApplicationError { fn from(value: VarError) -> Self { Self { msg: value.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR } } } diff --git a/src/endpoints.rs b/src/endpoints.rs index 320f9d2..3d34020 100644 --- a/src/endpoints.rs +++ b/src/endpoints.rs @@ -1,6 +1,8 @@ +use std::net::SocketAddr; + use axum::{ - extract::{Path, State}, - Json, + extract::{Path, State, ConnectInfo}, + Json, http::StatusCode, Extension, }; use chrono::{DateTime, TimeZone, Utc}; use rand::{distributions::Alphanumeric, Rng}; @@ -13,7 +15,7 @@ use crate::{ entity::{ availability::Availability, event::{Event, EventType}, - }, + } }; #[derive(Deserialize)] @@ -41,7 +43,6 @@ pub struct EventDto { pub struct CreateAvailabilitiesDto { availabilities: Vec, user_email: Option, - user_ip: String, user_name: String, } @@ -67,7 +68,7 @@ pub async fn create_event( let mut conn = match app_state.db_pool.acquire().await { Ok(c) => c, - Err(e) => return api::internal_server_error(e), + Err(e) => return api::internal_server_error(e.into()), }; let res = conn @@ -82,9 +83,31 @@ pub async fn create_event( let event_type: EventType = dto.event_type.into(); if matches!(event_type, EventType::Unknown) { - return Err(ApplicationError::new( - "Unknown event type, invalid variant.".to_string(), - )); + return Err(ApplicationError::new("Unknown event type, invalid variant.".to_string(), StatusCode::UNPROCESSABLE_ENTITY)); + } + + if matches!(event_type, EventType::SpecificDate) && dto.from_date.is_none() { + return Err(ApplicationError::new("SpecificDate event type supplied, but missing from_date".to_string(), StatusCode::UNPROCESSABLE_ENTITY)); + } + + if matches!(event_type, EventType::DateRange) { + match (dto.from_date, dto.to_date) { + (Some(from), Some(to)) => { + if from >= to { + return Err(ApplicationError::new("Supplied from_date is later than or equal to to_date".to_string(), StatusCode::UNPROCESSABLE_ENTITY)); + } + + if (to - from).num_days() < 1 { + return Err(ApplicationError::new("Difference between from_date and to_date is less than 1 day".to_string(), StatusCode::UNPROCESSABLE_ENTITY)); + } + + if (to - from).num_days() > 14 { + return Err(ApplicationError::new("Difference between from_date and to_date is greater than 14 days ( current supported maximum )".to_string(), StatusCode::UNPROCESSABLE_ENTITY)); + } + }, + _ => return Err(ApplicationError::new("DateRange event type supplied, but missing either from_date or to_date".to_string(), StatusCode::UNPROCESSABLE_ENTITY)) + } + } let event_id = db::insert_event_and_fetch_id( @@ -117,7 +140,7 @@ pub async fn create_event( }) .await; - api::ok::(res) + api::ok::(res) } pub async fn fetch_event( @@ -126,7 +149,7 @@ pub async fn fetch_event( ) -> UniversalResponseDto { let mut conn = match app_state.db_pool.acquire().await { Ok(c) => c, - Err(e) => return api::internal_server_error(e), + Err(e) => return api::internal_server_error(e.into()), }; let res = conn @@ -147,35 +170,39 @@ pub async fn fetch_event( }) .await; - api::ok::(res) + api::ok::(res) } pub async fn create_availabilities( State(app_state): State, Path(event_snowflake_id): Path, + ConnectInfo(addr): ConnectInfo, Json(dto): Json, ) -> UniversalResponseDto<()> { let mut conn = match app_state.db_pool.acquire().await { Ok(c) => c, - Err(e) => return api::internal_server_error(e), + Err(e) => return api::internal_server_error(e.into()), }; let res = conn .transaction(|txn| { Box::pin(async move { + let user_ip = format!("{}", addr.ip()); + let event = db::fetch_event_by_snowflake_id(txn, event_snowflake_id).await?; let current_availabilities = db::fetch_event_availabilities(txn, event.id).await?; let already_submitted = current_availabilities.iter().any(|a| { - (dto.user_email.is_none() && a.user_email == dto.user_email) - || a.user_ip == dto.user_ip + (dto.user_email.is_some() && a.user_email.is_some() && a.user_email == dto.user_email) + || a.user_ip == user_ip || a.user_name == dto.user_name }); if already_submitted { return Err(ApplicationError::new( "Availability already submitted".to_string(), + StatusCode::UNPROCESSABLE_ENTITY )); } @@ -190,7 +217,7 @@ pub async fn create_availabilities( from_date: a.from_date.naive_utc(), to_date: a.to_date.naive_utc(), user_email: dto.user_email.clone(), - user_ip: dto.user_ip.clone(), + user_ip: user_ip.clone(), user_name: dto.user_name.clone(), }, ) @@ -202,7 +229,7 @@ pub async fn create_availabilities( }) .await; - api::ok::<(), ApplicationError>(res) + api::ok::<()>(res) } pub async fn fetch_availabilities( @@ -211,7 +238,7 @@ pub async fn fetch_availabilities( ) -> UniversalResponseDto> { let mut conn = match app_state.db_pool.acquire().await { Ok(c) => c, - Err(e) => return api::internal_server_error(e), + Err(e) => return api::internal_server_error(e.into()), }; let res = conn @@ -234,5 +261,5 @@ pub async fn fetch_availabilities( }) .await; - api::ok::, ApplicationError>(res) + api::ok::>(res) } diff --git a/src/main.rs b/src/main.rs index 4151920..89070d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,7 +30,7 @@ async fn main() { let listener = TcpListener::bind(addr).await.unwrap(); - axum::serve(listener, routes.into_make_service()) + axum::serve(listener, routes.into_make_service_with_connect_info::()) .await .unwrap(); } \ No newline at end of file