Compare commits

..

No commits in common. "master" and "feat/openapi" have entirely different histories.

77 changed files with 1369 additions and 1680 deletions

View File

@ -1,30 +1,22 @@
variables:
- &node_image "node:22-alpine"
- &branch "master"
- &node_image 'node:22-alpine'
- &branch 'master'
when:
- event: push
branch: *branch
steps:
- name: Generate TypeScript types
image: *node_image
commands:
- cd types
- yarn install --frozen-lockfile
- yarn openapi-ts
- name: Install server dependencies
image: *node_image
commands:
- cd server
- yarn install --frozen-lockfile
depends_on: [Generate TypeScript types]
- name: Install client dependencies
image: *node_image
commands:
- cd client
- yarn install --frozen-lockfile
depends_on: [Generate TypeScript types]
- name: Build server
depends_on: [Install server dependencies]
image: *node_image

View File

@ -9,8 +9,6 @@ WORKDIR /build
COPY types/package.json ./types/
COPY types/yarn.lock ./types/
COPY types/api.yml ./types/
COPY types/schemas ./types/schemas/
COPY types/paths ./types/paths/
COPY types/openapi-ts.config.ts ./types/
# Zkopírování závislostí - server
@ -48,6 +46,7 @@ COPY client/src ./client/src
COPY client/public ./client/public
# Zkopírování společných typů
COPY types/RequestTypes.ts ./types/
COPY types/index.ts ./types/
# Vygenerování společných typů z OpenAPI
@ -80,7 +79,7 @@ COPY --from=builder /build/server/dist ./
COPY --from=builder /build/client/dist ./public
# Zkopírování produkčních .env serveru
COPY /server/.env.production ./server
COPY /server/.env.production ./server/src
# Zkopírování konfigurace easter eggů
RUN if [ -f /server/.easter-eggs.json ]; then cp /server/.easter-eggs.json ./server/; fi

View File

@ -1,6 +1,7 @@
import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react';
import 'bootstrap/dist/css/bootstrap.min.css';
import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket';
import { addPizza, createPizzaDay, deletePizzaDay, finishDelivery, finishOrder, lockPizzaDay, removePizza, unlockPizzaDay, updatePizzaDayNote } from './api/PizzaDayApi';
import { useAuth } from './context/auth';
import Login from './Login';
import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap';
@ -15,11 +16,15 @@ import { useSettings } from './context/settings';
import Footer from './components/Footer';
import { faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch } from '@fortawesome/free-solid-svg-icons';
import Loader from './components/Loader';
import { getData, errorHandler, getQrUrl } from './api/Api';
import { addChoice, removeChoices, removeChoice, changeDepartureTime, jdemeObed, updateNote } from './api/FoodApi';
import { getHumanDateTime, isInTheFuture } from './Utils';
import NoteModal from './components/modals/NoteModal';
import { useEasterEgg } from './context/eggs';
import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime } from '../../types';
import { getLunchChoiceName } from './enums';
import { getImage } from './api/EasterEggApi';
import { Link } from 'react-router';
import { STATS_URL } from './AppRoutes';
import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LunchChoices, UserLunchChoice, PizzaVariant } from '../../types';
const EVENT_CONNECT = "connect"
@ -36,7 +41,7 @@ const EASTER_EGG_DEFAULT_DURATION = 0.75;
function App() {
const auth = useAuth();
const settings = useSettings();
const [easterEgg, _] = useEasterEgg(auth);
const [easterEgg, easterEggLoading] = useEasterEgg(auth);
const [isConnected, setIsConnected] = useState<boolean>(false);
const [data, setData] = useState<ClientData>();
const [food, setFood] = useState<RestaurantDayMenuMap>();
@ -60,15 +65,14 @@ function App() {
// Načtení dat po přihlášení
useEffect(() => {
if (!auth?.login) {
if (!auth || !auth.login) {
return
}
getData().then(response => {
const data = response.data
getData().then(({ data }) => {
if (data) {
setData(data);
setDayIndex(data.dayIndex);
dayIndexRef.current = data.dayIndex;
setDayIndex(data.weekIndex);
dayIndexRef.current = data.weekIndex;
setFood(data.menus);
}
}).catch(e => {
@ -78,15 +82,12 @@ function App() {
// Přenačtení pro zvolený den
useEffect(() => {
if (!auth?.login) {
if (!auth || !auth.login) {
return
}
getData({ query: { dayIndex: dayIndex } }).then(response => {
const data = response.data;
getData(dayIndex).then((data: ClientData) => {
setData(data);
if (data) {
setFood(data.menus);
}
setFood(data.menus);
}).catch(e => {
setFailure(true);
})
@ -95,9 +96,11 @@ function App() {
// Registrace socket eventů
useEffect(() => {
socket.on(EVENT_CONNECT, () => {
// console.log("Connected!");
setIsConnected(true);
});
socket.on(EVENT_DISCONNECT, () => {
// console.log("Disconnected!");
setIsConnected(false);
});
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
@ -116,7 +119,7 @@ function App() {
}, [socket]);
useEffect(() => {
if (!auth?.login) {
if (!auth || !auth.login) {
return
}
// TODO tohle občas náhodně nezafunguje, nutno přepsat, viz https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780
@ -141,10 +144,10 @@ function App() {
useEffect(() => {
if (choiceRef?.current?.value && choiceRef.current.value !== "") {
const locationKey = choiceRef.current.value as LunchChoice;
const locationKey = choiceRef.current.value as keyof typeof LunchChoice;
const restaurantKey = Object.keys(Restaurant).indexOf(locationKey);
if (restaurantKey > -1 && food) {
const restaurant = Object.keys(Restaurant)[restaurantKey] as Restaurant;
const restaurant = Object.keys(Restaurant)[restaurantKey] as keyof typeof Restaurant;
setFoodChoiceList(food[restaurant]?.food);
setClosed(food[restaurant]?.closed ?? false);
} else {
@ -176,9 +179,9 @@ function App() {
// Stažení a nastavení easter egg obrázku
useEffect(() => {
if (auth?.login && easterEgg?.url && !eggImage) {
getEasterEggImage({ path: { url: easterEgg.url } }).then(response => {
if (response.data) {
setEggImage(response.data);
getImage(easterEgg.url).then(data => {
if (data) {
setEggImage(data);
// Smazání obrázku z DOMu po animaci
setTimeout(() => {
if (eggRef?.current) {
@ -190,18 +193,18 @@ function App() {
}
}, [auth?.login, easterEgg?.duration, easterEgg?.url, eggImage]);
const doAddClickFoodChoice = async (location: LunchChoice, foodIndex?: number) => {
const doAddClickFoodChoice = async (location: keyof typeof LunchChoice, foodIndex?: number) => {
if (document.getSelection()?.type !== 'Range') { // pouze pokud se nejedná o výběr textu
if (auth?.login) {
await addChoice({ body: { locationKey: location, foodIndex, dayIndex } });
await errorHandler(() => addChoice(location, foodIndex, dayIndex));
}
}
}
const doAddChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const locationKey = event.target.value as LunchChoice;
const locationKey = event.target.value as keyof typeof LunchChoice;
if (auth?.login) {
await addChoice({ body: { locationKey, dayIndex } });
await errorHandler(() => addChoice(locationKey, undefined, dayIndex));
if (foodChoiceRef.current?.value) {
foodChoiceRef.current.value = "";
}
@ -216,16 +219,16 @@ function App() {
const doAddFoodChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
if (event.target.value && foodChoiceList?.length && choiceRef.current?.value) {
const locationKey = choiceRef.current.value as LunchChoice;
const locationKey = choiceRef.current.value as keyof typeof LunchChoice;
if (auth?.login) {
await addChoice({ body: { locationKey, foodIndex: Number(event.target.value), dayIndex } });
await errorHandler(() => addChoice(locationKey, Number(event.target.value), dayIndex));
}
}
}
const doRemoveChoices = async (locationKey: LunchChoice) => {
const doRemoveChoices = async (locationKey: keyof typeof LunchChoice) => {
if (auth?.login) {
await removeChoices({ body: { locationKey, dayIndex } });
await errorHandler(() => removeChoices(locationKey, dayIndex));
// Vyresetujeme výběr, aby bylo jasné pro který případně vybíráme jídlo
if (choiceRef?.current?.value) {
choiceRef.current.value = "";
@ -236,9 +239,9 @@ function App() {
}
}
const doRemoveFoodChoice = async (locationKey: LunchChoice, foodIndex: number) => {
const doRemoveFoodChoice = async (locationKey: keyof typeof LunchChoice, foodIndex: number) => {
if (auth?.login) {
await removeChoice({ body: { locationKey, foodIndex, dayIndex } });
await errorHandler(() => removeChoice(locationKey, foodIndex, dayIndex));
if (choiceRef?.current?.value) {
choiceRef.current.value = "";
}
@ -250,7 +253,7 @@ function App() {
const saveNote = async (note?: string) => {
if (auth?.login) {
await updateNote({ body: { note, dayIndex } });
await errorHandler(() => updateNote(note, dayIndex));
setNoteModalOpen(false);
}
}
@ -274,18 +277,18 @@ function App() {
const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => {
if (auth?.login && data?.pizzaList) {
if (typeof value !== 'string') {
if (!(typeof value === 'string')) {
throw Error('Nepodporovaný typ hodnoty');
}
const s = value.split('|');
const pizzaIndex = Number.parseInt(s[0]);
const pizzaSizeIndex = Number.parseInt(s[1]);
await addPizza({ body: { pizzaIndex, pizzaSizeIndex } });
await addPizza(pizzaIndex, pizzaSizeIndex);
}
}
const handlePizzaDelete = async (pizzaOrder: PizzaVariant) => {
await removePizza({ body: { pizzaOrder } });
await removePizza(pizzaOrder);
}
const handlePizzaPoznamkaChange = async () => {
@ -293,7 +296,7 @@ function App() {
alert("Poznámka může mít maximálně 70 znaků");
return;
}
updatePizzaDayNote({ body: { note: pizzaPoznamkaRef.current?.value } });
updatePizzaDayNote(pizzaPoznamkaRef.current?.value);
}
// const addToCart = async () => {
@ -322,7 +325,7 @@ function App() {
const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => {
if (foodChoiceList?.length && choiceRef.current?.value) {
await changeDepartureTime({ body: { time: event.target.value as DepartureTime, dayIndex } });
await changeDepartureTime(event.target.value, dayIndex);
}
}
@ -340,7 +343,7 @@ function App() {
}
}
const renderFoodTable = (location: Restaurant, menu: RestaurantDayMenu) => {
const renderFoodTable = (location: keyof typeof Restaurant, menu: RestaurantDayMenu) => {
let content;
if (menu?.closed) {
content = <h3>Zavřeno</h3>
@ -362,13 +365,13 @@ function App() {
content = <h3>Chyba načtení dat</h3>
}
return <Col md={12} lg={3} className='mt-3'>
<h3 style={{ cursor: 'pointer' }} onClick={() => doAddClickFoodChoice(location)}>{getLunchChoiceName(location)}</h3>
<h3 style={{ cursor: 'pointer' }} onClick={() => doAddClickFoodChoice(location, undefined)}>{location}</h3>
{menu?.lastUpdate && <small>Poslední aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}</small>}
{content}
</Col>
}
if (!auth?.login) {
if (!auth || !auth.login) {
return <Login />;
}
@ -408,12 +411,12 @@ function App() {
<div className='wrapper'>
{data.isWeekend ? <h4>Užívejte víkend :)</h4> : <>
<Alert variant={'primary'}>
{/* <img alt="" src='hat.png' style={{ position: "absolute", width: "70px", rotate: "-45deg", left: -40, top: -58 }} />
<img alt="" src='snowman.png' style={{ position: "absolute", height: "110px", right: 10, top: 5 }} /> */}
<img src='hat.png' style={{ position: "absolute", width: "70px", rotate: "-45deg", left: -40, top: -58 }} />
<img src='snowman.png' style={{ position: "absolute", height: "110px", right: 10, top: 5 }} />
Poslední změny:
<ul>
<li>Podpora ručního refresh týdne</li>
<li>Úprava pro přepracovanou podobu stránek Sladovnická</li>
<li>Možnost výběru restaurace a jídel kliknutím v tabulce</li>
<li><Link to={STATS_URL}>Statistiky</Link></li>
</ul>
</Alert>
{dayIndex != null &&
@ -426,6 +429,7 @@ function App() {
<Row className='food-tables'>
{/* TODO zjednodušit, stačí iterovat klíče typu Restaurant */}
{food['SLADOVNICKA'] && renderFoodTable('SLADOVNICKA', food['SLADOVNICKA'])}
{/* {food['UMOTLIKU'] && renderFoodTable('UMOTLIKU', food['UMOTLIKU'])} */}
{food['TECHTOWER'] && renderFoodTable('TECHTOWER', food['TECHTOWER'])}
{food['ZASTAVKAUMICHALA'] && renderFoodTable('ZASTAVKAUMICHALA', food['ZASTAVKAUMICHALA'])}
{food['SENKSERIKOVA'] && renderFoodTable('SENKSERIKOVA', food['SENKSERIKOVA'])}
@ -436,19 +440,19 @@ function App() {
<p>{`Jak to ${dayIndex == null || dayIndex === data.todayDayIndex ? 'dnes' : 'tento den'} vidíš s obědem?`}</p>
<Form.Select ref={choiceRef} onChange={doAddChoice}>
<option></option>
{Object.entries(LunchChoice)
{Object.keys(Restaurant)
.filter(entry => {
const locationKey = entry[0] as Restaurant;
const locationKey = entry as keyof typeof Restaurant;
return !food[locationKey]?.closed;
})
.map(entry => <option key={entry[0]} value={entry[0]}>{getLunchChoiceName(entry[1])}</option>)}
.map(entry => <option key={entry[0]} value={entry[0]}>{entry[1]}</option>)}
</Form.Select>
<small>Je možné vybrat jen jednu možnost. Výběr jiné odstraní předchozí.</small>
{foodChoiceList && !closed && <>
<p style={{ marginTop: "10px" }}>Na co dobrého? <small>(nepovinné)</small></p>
<Form.Select ref={foodChoiceRef} onChange={doAddFoodChoice}>
<option></option>
{foodChoiceList.map((food, index) => <option key={food.name} value={index}>{food.name}</option>)}
{foodChoiceList.map((food, index) => <option key={index} value={index}>{food.name}</option>)}
</Form.Select>
</>}
{foodChoiceList && !closed && <>
@ -465,8 +469,8 @@ function App() {
<Table bordered className='mt-5'>
<tbody>
{Object.keys(data.choices).map(key => {
const locationKey = key as LunchChoice;
const locationName = getLunchChoiceName(locationKey);
const locationKey = key as keyof typeof LunchChoice;
const locationName = LunchChoice[locationKey];
const loginObject = data.choices[locationKey];
if (!loginObject) {
return;
@ -487,7 +491,7 @@ function App() {
const userPayload = entry[1];
const userChoices = userPayload?.selectedFoods;
const trusted = userPayload?.trusted || false;
return <tr key={entry[0]}>
return <tr key={index}>
<td>
{trusted && <span className='trusted-icon'>
<FontAwesomeIcon title='Uživatel ověřený doménovým přihlášením' icon={faCircleCheck} style={{ cursor: "help" }} />
@ -499,13 +503,13 @@ function App() {
setNoteModalOpen(true);
}} title='Upravit poznámku' className='action-icon' icon={faNoteSticky} />}
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => {
doRemoveChoices(key as LunchChoice);
doRemoveChoices(key as keyof typeof LunchChoice);
}} title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`} className='action-icon' icon={faTrashCan} />}
</td>
{userChoices?.length && food ? <td>
<ul>
{userChoices?.map(foodIndex => {
const restaurantKey = key as Restaurant;
const restaurantKey = key as keyof typeof Restaurant;
const foodName = food[restaurantKey]?.food?.[foodIndex].name;
return <li key={foodIndex}>
{foodName}
@ -583,6 +587,9 @@ function App() {
<Button className='danger mb-3' title="Umožní znovu editovat objednávky." onClick={async () => {
await unlockPizzaDay();
}}>Odemknout</Button>
{/* <Button className='danger mb-3' style={{ marginLeft: '20px' }} onClick={async () => {
await addToCart();
}}>Přidat vše do košíku</Button> */}
<Button className='danger mb-3' style={{ marginLeft: '20px' }} title={noOrders ? "Nelze objednat - neexistuje žádná objednávka" : "Použij po objednání. Objednávky zůstanou zamčeny."} disabled={noOrders} onClick={async () => {
await finishOrder();
}}>Objednáno</Button>
@ -600,7 +607,7 @@ function App() {
await lockPizzaDay();
}}>Vrátit do "uzamčeno"</Button>
<Button className='danger mb-3' style={{ marginLeft: '20px' }} title="Nastaví stav na 'Doručeno' - koncový stav." onClick={async () => {
await finishDelivery({ body: { bankAccount: settings?.bankAccount, bankAccountHolder: settings?.holderName } });
await finishDelivery(settings?.bankAccount, settings?.holderName);
}}>Doručeno</Button>
</div>
}
@ -642,7 +649,7 @@ function App() {
data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr ?
<div className='qr-code'>
<h3>QR platba</h3>
<img src={`/api/qr?login=${auth.login}`} alt='QR kód' />
<img src={getQrUrl(auth.login)} alt='QR kód' />
</div> : null
}
</div>
@ -650,7 +657,7 @@ function App() {
</div>
}
</div>
</> || "Jejda, něco se nám nepovedlo :("}
</>}
</div>
<Footer />
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />

View File

@ -1,6 +1,6 @@
import { Routes, Route } from "react-router-dom";
import { ProvideSettings } from "./context/settings";
// import Snowfall from "react-snowfall";
import Snowfall from "react-snowfall";
import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage";
@ -16,12 +16,12 @@ export default function AppRoutes() {
<ProvideSettings>
<SocketContext.Provider value={socket}>
<>
{/* <Snowfall style={{
<Snowfall style={{
zIndex: 2,
position: 'fixed',
width: '100vw',
height: '100vh'
}} /> */}
}} />
<App />
</>
<ToastContainer />

View File

@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { Button } from 'react-bootstrap';
import { useAuth } from './context/auth';
import { login } from '../../types';
import { login } from './api/Api';
import './Login.css';
/**
@ -14,10 +14,9 @@ export default function Login() {
useEffect(() => {
if (auth && !auth.login) {
// Vyzkoušíme přihlášení "naprázdno", pokud projde, přihlásili nás trusted headers
login().then(response => {
const token = response.data;
login().then(token => {
if (token) {
auth?.setToken(token as unknown as string); // TODO vyřešit, API definice je špatně, je to skutečně string
auth?.setToken(token);
}
}).catch(error => {
// nezajímá nás
@ -26,16 +25,17 @@ export default function Login() {
}, [auth]);
const doLogin = useCallback(async () => {
const length = loginRef?.current?.value.length && loginRef.current.value.replace(/\s/g, '').length
const length = loginRef?.current?.value && loginRef?.current?.value.length && loginRef.current.value.replace(/\s/g, '').length
if (length) {
const response = await login({ body: { login: loginRef.current?.value } });
if (response.data) {
auth?.setToken(response.data as unknown as string); // TODO vyřešit
// TODO odchytávat cokoliv mimo 200
const token = await login(loginRef.current?.value);
if (token) {
auth?.setToken(token);
}
}
}, [auth]);
if (!auth?.login) {
if (!auth || !auth.login) {
return <div className='login'>
<h1>Luncher</h1>
<h4 style={{ marginBottom: "50px" }}>Aplikace pro profesionální management obědů</h4>

View File

@ -93,7 +93,7 @@ export function formatDate(date: Date, format?: string) {
let month = String(date.getMonth() + 1).padStart(2, "0");
let year = String(date.getFullYear());
const f = format ?? 'YYYY-MM-DD';
const f = (format === undefined) ? 'YYYY-MM-DD' : format;
return f.replace('DD', day).replace('MM', month).replace('YYYY', year);
}

83
client/src/api/Api.ts Normal file
View File

@ -0,0 +1,83 @@
import { toast } from "react-toastify";
import { getToken } from "../Utils";
/**
* Wrapper pro volání API, u kterých chceme automaticky zobrazit toaster s chybou ze serveru.
*
* @param apiFunction volaná API funkce
*/
export function errorHandler<T>(apiFunction: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
apiFunction().then((result) => {
resolve(result);
}).catch(e => {
toast.error(e.message, { theme: "colored" });
});
});
}
async function request<TResponse>(
url: string,
config: RequestInit = {}
): Promise<TResponse> {
config.headers = config?.headers ? new Headers(config.headers) : new Headers();
config.headers.set("Authorization", `Bearer ${getToken()}`);
try {
const response = await fetch(url, config);
if (!response.ok) {
// TODO tohle je blbě, jelikož automaticky očekáváme, že v případě chyby přijde vždy JSON, což není pravda
const json = await response.json();
// Vyhodíme samotnou hlášku z odpovědi, odchytí si jí errorHandler
throw new Error(json.error);
}
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
return response.json() as TResponse;
} else {
return response.text() as TResponse;
}
} catch (e) {
return Promise.reject(e);
}
}
async function blobRequest(
url: string,
config: RequestInit = {}
): Promise<Blob> {
config.headers = config?.headers ? new Headers(config.headers) : new Headers();
config.headers.set("Authorization", `Bearer ${getToken()}`);
try {
const response = await fetch(url, config);
if (!response.ok) {
const json = await response.json();
// Vyhodíme samotnou hlášku z odpovědi, odchytí si jí errorHandler
throw new Error(json.error);
}
return response.blob()
} catch (e) {
return Promise.reject(e);
}
}
export const api = {
get: <TResponse>(url: string) => request<TResponse>(url),
blobGet: (url: string) => blobRequest(url),
post: <TBody, TResponse>(url: string, body?: TBody) => request<TResponse>(url, { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }),
}
export const getQrUrl = (login: string) => {
return `/api/qr?login=${login}`;
}
export const getData = async (dayIndex?: number) => {
let url = '/api/data';
if (dayIndex != null) {
url += '?dayIndex=' + dayIndex;
}
return await api.get<any>(url);
}
export const login = async (login?: string) => {
return await api.post<any, any>('/api/login', { login });
}

8
client/src/api/Client.ts Normal file
View File

@ -0,0 +1,8 @@
import { client } from '../../../types/gen/client.gen';
import { getToken } from '../Utils';
client.setConfig({
auth: () => getToken(),
});
export default client

View File

@ -0,0 +1,12 @@
import { EasterEgg } from "../../../types";
import { api } from "./Api";
const EASTER_EGGS_API_PREFIX = '/api/easterEggs';
export const getEasterEgg = async (): Promise<EasterEgg | undefined> => {
return await api.get<EasterEgg>(`${EASTER_EGGS_API_PREFIX}`);
}
export const getImage = async (url: string) => {
return await api.blobGet(`${EASTER_EGGS_API_PREFIX}/${url}`);
}

29
client/src/api/FoodApi.ts Normal file
View File

@ -0,0 +1,29 @@
import { AddChoiceRequest, ChangeDepartureTimeRequest, RemoveChoiceRequest, RemoveChoicesRequest, UpdateNoteRequest } from "../../../types";
import { LunchChoice } from "../../../types";
import { api } from "./Api";
const FOOD_API_PREFIX = '/api/food';
export const addChoice = async (locationKey: keyof typeof LunchChoice, foodIndex?: number, dayIndex?: number) => {
return await api.post<AddChoiceRequest, void>(`${FOOD_API_PREFIX}/addChoice`, { locationKey, foodIndex, dayIndex });
}
export const removeChoices = async (locationKey: keyof typeof LunchChoice, dayIndex?: number) => {
return await api.post<RemoveChoicesRequest, void>(`${FOOD_API_PREFIX}/removeChoices`, { locationKey, dayIndex });
}
export const removeChoice = async (locationKey: keyof typeof LunchChoice, foodIndex: number, dayIndex?: number) => {
return await api.post<RemoveChoiceRequest, void>(`${FOOD_API_PREFIX}/removeChoice`, { locationKey, foodIndex, dayIndex });
}
export const updateNote = async (note?: string, dayIndex?: number) => {
return await api.post<UpdateNoteRequest, void>(`${FOOD_API_PREFIX}/updateNote`, { note, dayIndex });
}
export const changeDepartureTime = async (time: string, dayIndex?: number) => {
return await api.post<ChangeDepartureTimeRequest, void>(`${FOOD_API_PREFIX}/changeDepartureTime`, { time, dayIndex });
}
export const jdemeObed = async () => {
return await api.post<undefined, void>(`${FOOD_API_PREFIX}/jdemeObed`);
}

View File

@ -0,0 +1,45 @@
import { AddPizzaRequest, FinishDeliveryRequest, RemovePizzaRequest, UpdatePizzaDayNoteRequest, UpdatePizzaFeeRequest } from "../../../types";
import { PizzaVariant } from "../../../types";
import { api } from "./Api";
const PIZZADAY_API_PREFIX = '/api/pizzaDay';
export const createPizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/create`);
}
export const deletePizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/delete`);
}
export const lockPizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/lock`);
}
export const unlockPizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/unlock`);
}
export const finishOrder = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/finishOrder`);
}
export const finishDelivery = async (bankAccount?: string, bankAccountHolder?: string) => {
return await api.post<FinishDeliveryRequest, void>(`${PIZZADAY_API_PREFIX}/finishDelivery`, { bankAccount, bankAccountHolder });
}
export const addPizza = async (pizzaIndex: number, pizzaSizeIndex: number) => {
return await api.post<AddPizzaRequest, void>(`${PIZZADAY_API_PREFIX}/add`, { pizzaIndex, pizzaSizeIndex });
}
export const removePizza = async (pizzaOrder: PizzaVariant) => {
return await api.post<RemovePizzaRequest, void>(`${PIZZADAY_API_PREFIX}/remove`, { pizzaOrder });
}
export const updatePizzaDayNote = async (note?: string) => {
return await api.post<UpdatePizzaDayNoteRequest, void>(`${PIZZADAY_API_PREFIX}/updatePizzaDayNote`, { note });
}
export const updatePizzaFee = async (login: string, text?: string, price?: number) => {
return await api.post<UpdatePizzaFeeRequest, void>(`${PIZZADAY_API_PREFIX}/updatePizzaFee`, { login, text, price });
}

View File

@ -0,0 +1,8 @@
import { WeeklyStats } from "../../../types";
import { api } from "./Api";
const STATS_API_PREFIX = '/api/stats';
export const getStats = async (startDate: string, endDate: string) => {
return await api.get<WeeklyStats>(`${STATS_API_PREFIX}?startDate=${startDate}&endDate=${endDate}`);
}

View File

@ -0,0 +1,13 @@
import { UpdateFeatureVoteRequest } from "../../../types";
import { FeatureRequest } from "../../../types";
import { api } from "./Api";
const VOTING_API_PREFIX = '/api/voting';
export const getFeatureVotes = async () => {
return await api.get<FeatureRequest[]>(`${VOTING_API_PREFIX}/getVotes`);
}
export const updateFeatureVote = async (option: FeatureRequest, active: boolean) => {
return await api.post<UpdateFeatureVoteRequest, void>(`${VOTING_API_PREFIX}/updateVote`, { option, active });
}

View File

@ -4,10 +4,12 @@ import { useAuth } from "../context/auth";
import SettingsModal from "./modals/SettingsModal";
import { useSettings } from "../context/settings";
import FeaturesVotingModal from "./modals/FeaturesVotingModal";
import { errorHandler } from "../api/Api";
import { getFeatureVotes, updateFeatureVote } from "../api/VotingApi";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
import { useNavigate } from "react-router";
import { STATS_URL } from "../AppRoutes";
import { FeatureRequest, getVotes, updateVote } from "../../../types";
import { FeatureRequest } from "../../../types";
export default function Header() {
const auth = useAuth();
@ -16,12 +18,12 @@ export default function Header() {
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[]>([]);
useEffect(() => {
if (auth?.login) {
getVotes().then(response => {
setFeatureVotes(response.data);
getFeatureVotes().then(votes => {
setFeatureVotes(votes);
})
}
}, [auth?.login]);
@ -76,7 +78,7 @@ export default function Header() {
cislo = cislo.padStart(16, '0');
}
let sum = 0;
for (let i = 0; i < cislo.length; i++) {
for (var i = 0; i < cislo.length; i++) {
const char = cislo.charAt(i);
const order = (cislo.length - 1) - i;
const weight = (2 ** order) % 11;
@ -97,8 +99,8 @@ export default function Header() {
}
const saveFeatureVote = async (option: FeatureRequest, active: boolean) => {
await updateVote({ body: { option, active } });
const votes = [...featureVotes || []];
await errorHandler(() => updateFeatureVote(option, active));
const votes = [...featureVotes];
if (active) {
votes.push(option);
} else {

View File

@ -2,15 +2,15 @@ import { IconDefinition } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
type Props = {
title?: string,
title?: String,
icon: IconDefinition,
description: string,
animation?: string,
description: String,
animation?: String,
}
function Loader(props: Readonly<Props>) {
function Loader(props: Props) {
return <div className='loader'>
<h1>{props.title ?? 'Prosím čekejte...'}</h1>
<h1>{props.title || 'Prosím čekejte...'}</h1>
<FontAwesomeIcon icon={props.icon} className={`loader-icon mb-3 ` + (props.animation ?? '')} />
<p>{props.description}</p>
</div>

View File

@ -1,6 +1,7 @@
import { Table } from "react-bootstrap";
import PizzaOrderRow from "./PizzaOrderRow";
import { PizzaDayState, PizzaOrder, PizzaVariant, updatePizzaFee } from "../../../types";
import { updatePizzaFee } from "../api/PizzaDayApi";
import { PizzaDayState, PizzaOrder, PizzaVariant } from "../../../types";
type Props = {
state: PizzaDayState,
@ -9,9 +10,9 @@ type Props = {
creator: string,
}
export default function PizzaOrderList({ state, orders, onDelete, creator }: Readonly<Props>) {
export default function PizzaOrderList({ state, orders, onDelete, creator }: Props) {
const saveFees = async (customer: string, text?: string, price?: number) => {
await updatePizzaFee({ body: { login: customer, text, price } });
await updatePizzaFee(customer, text, price);
}
if (!orders?.length) {
@ -20,24 +21,26 @@ export default function PizzaOrderList({ state, orders, onDelete, creator }: Rea
const total = orders.reduce((total, order) => total + order.totalPrice, 0);
return <Table className="mt-3" striped bordered hover>
<thead>
<tr>
<th>Jméno</th>
<th>Objednávka</th>
<th>Poznámka</th>
<th>Příplatek</th>
<th>Cena</th>
</tr>
</thead>
<tbody>
{orders.map(order => <tr key={order.customer}>
<PizzaOrderRow creator={creator} state={state} order={order} onDelete={onDelete} onFeeModalSave={saveFees} />
</tr>)}
<tr style={{ fontWeight: 'bold' }}>
<td colSpan={4}>Celkem</td>
<td>{`${total}`}</td>
</tr>
</tbody>
</Table>
return <>
<Table className="mt-3" striped bordered hover>
<thead>
<tr>
<th>Jméno</th>
<th>Objednávka</th>
<th>Poznámka</th>
<th>Příplatek</th>
<th>Cena</th>
</tr>
</thead>
<tbody>
{orders.map(order => <tr key={order.customer}>
<PizzaOrderRow creator={creator} state={state} order={order} onDelete={onDelete} onFeeModalSave={saveFees} />
</tr>)}
<tr style={{ fontWeight: 'bold' }}>
<td colSpan={4}>Celkem</td>
<td>{`${total}`}</td>
</tr>
</tbody>
</Table>
</>
}

View File

@ -13,19 +13,19 @@ type Props = {
onFeeModalSave: (customer: string, name?: string, price?: number) => void,
}
export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeModalSave }: Readonly<Props>) {
export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeModalSave }: Props) {
const auth = useAuth();
const [isFeeModalOpen, setIsFeeModalOpen] = useState<boolean>(false);
const [isFeeModalOpen, setFeeModalOpen] = useState<boolean>(false);
const saveFees = (customer: string, text?: string, price?: number) => {
onFeeModalSave(customer, text, price);
setIsFeeModalOpen(false);
setFeeModalOpen(false);
}
return <>
<td>{order.customer}</td>
<td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder =>
<span key={pizzaOrder.name}>
<td>{order.pizzaList!.map<React.ReactNode>((pizzaOrder, index) =>
<span key={index}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
{auth?.login === order.customer && state === PizzaDayState.CREATED &&
<FontAwesomeIcon onClick={() => {
@ -35,11 +35,11 @@ export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeMo
</span>)
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
</td>
<td style={{ maxWidth: "200px" }}>{order.note ?? '-'}</td>
<td style={{ maxWidth: "200px" }}>{order.note || '-'}</td>
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price}${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
<td>
{order.totalPrice} {auth?.login === creator && state === PizzaDayState.CREATED && <FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} title='Nastavit příplatek' className='action-icon' icon={faMoneyBill1} />}
{order.totalPrice} {auth?.login === creator && state === PizzaDayState.CREATED && <FontAwesomeIcon onClick={() => { setFeeModalOpen(true) }} title='Nastavit příplatek' className='action-icon' icon={faMoneyBill1} />}
</td>
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} />
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} />
</>
}

View File

@ -9,7 +9,7 @@ type Props = {
}
/** Modální dialog pro hlasování o nových funkcích. */
export default function FeaturesVotingModal({ isOpen, onClose, onChange, initialValues }: Readonly<Props>) {
export default function FeaturesVotingModal({ isOpen, onClose, onChange, initialValues }: Props) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.currentTarget.value as FeatureRequest, e.currentTarget.checked);
@ -31,7 +31,7 @@ export default function FeaturesVotingModal({ isOpen, onClose, onChange, initial
label={FeatureRequest[key]}
onChange={handleChange}
value={key}
defaultChecked={initialValues?.includes(key as FeatureRequest)}
defaultChecked={initialValues && initialValues.includes(key as FeatureRequest)}
/>
})}
<p className="mt-3" style={{ fontSize: '12px' }}>Něco jiného? Dejte vědět.</p>

View File

@ -8,7 +8,7 @@ type Props = {
}
/** Modální dialog pro úpravu obecné poznámky. */
export default function NoteModal({ isOpen, onClose, onSave }: Readonly<Props>) {
export default function NoteModal({ isOpen, onClose, onSave }: Props) {
const note = useRef<HTMLInputElement>(null);
const save = () => {

View File

@ -10,17 +10,17 @@ type Props = {
}
/** Modální dialog pro nastavení příplatků za pizzu. */
export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose, onSave, initialValues }: Readonly<Props>) {
export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose, onSave, initialValues }: Props) {
const textRef = useRef<HTMLInputElement>(null);
const priceRef = useRef<HTMLInputElement>(null);
const doSubmit = () => {
onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value ?? "0"));
onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value || "0"));
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value ?? "0"));
onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value || "0"));
}
}

View File

@ -23,7 +23,7 @@ type Result = {
}
/** Modální dialog pro výpočet výhodnosti pizzy. */
export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<Props>) {
export default function PizzaCalculatorModal({ isOpen, onClose }: Props) {
const diameter1Ref = useRef<HTMLInputElement>(null);
const price1Ref = useRef<HTMLInputElement>(null);
const diameter2Ref = useRef<HTMLInputElement>(null);
@ -37,7 +37,9 @@ export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<Props
// 1. pizza
if (diameter1Ref.current?.value) {
const diameter1 = parseInt(diameter1Ref.current?.value);
r.pizza1 ??= {};
if (!r.pizza1) {
r.pizza1 = {};
}
if (diameter1 && diameter1 > 0) {
r.pizza1.diameter = diameter1;
r.pizza1.area = Math.PI * Math.pow(diameter1 / 2, 2);
@ -57,7 +59,9 @@ export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<Props
// 2. pizza
if (diameter2Ref.current?.value) {
const diameter2 = parseInt(diameter2Ref.current?.value);
r.pizza2 ??= {};
if (!r.pizza2) {
r.pizza2 = {};
}
if (diameter2 && diameter2 > 0) {
r.pizza2.diameter = diameter2;
r.pizza2.area = Math.PI * Math.pow(diameter2 / 2, 2);

View File

@ -1,5 +1,5 @@
import { useRef, useState } from "react";
import { Modal, Button, Alert } from "react-bootstrap"
import { useRef } from "react";
import { Modal, Button } from "react-bootstrap"
import { useSettings } from "../../context/settings";
type Props = {
@ -9,47 +9,12 @@ type Props = {
}
/** Modální dialog pro uživatelská nastavení. */
export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Props>) {
export default function SettingsModal({ isOpen, onClose, onSave }: Props) {
const settings = useSettings();
const bankAccountRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
const hideSoupsRef = useRef<HTMLInputElement>(null);
// Pro refresh jidel
const refreshPassRef = useRef<HTMLInputElement>(null);
const refreshTypeRef = useRef<HTMLSelectElement>(null);
const [refreshLoading, setRefreshLoading] = useState(false);
const [refreshMessage, setRefreshMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const handleRefresh = async () => {
const password = refreshPassRef.current?.value;
const type = refreshTypeRef.current?.value;
if (!password || !type) {
setRefreshMessage({ type: 'error', text: 'Zadejte heslo a typ refresh.' });
return;
}
setRefreshLoading(true);
setRefreshMessage(null);
try {
const res = await fetch(`/api/food/refresh?type=${type}&heslo=${encodeURIComponent(password)}`);
const data = await res.json();
if (res.ok) {
setRefreshMessage({ type: 'success', text: 'Uspesny fetch' });
if (refreshPassRef.current) {
// Clean hesla xd
refreshPassRef.current.value = '';
}
} else {
setRefreshMessage({ type: 'error', text: data.error || 'Chyba při obnovování jídelníčku.' });
}
} catch (error) { }
finally {
setRefreshLoading(false);
}
};
return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Nastavení</h2></Modal.Title>
@ -59,48 +24,6 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
<span title="V nabídkách nebudou zobrazovány polévky. Tato funkce je experimentální, a zejména u TechTower bývá často problém polévky spolehlivě rozeznat. V případě využití této funkce průběžně nahlašujte stále se zobrazující polévky." style={{ "cursor": "help" }}>
<input ref={hideSoupsRef} type="checkbox" defaultChecked={settings?.hideSoups} /> Skrýt polévky
</span>
<hr />
<h4>Obnovit jídelníček</h4>
<p>Ruční refresh dat z restaurací.</p>
{refreshMessage && (
<Alert variant={refreshMessage.type === 'success' ? 'success' : 'danger'}>
{refreshMessage.text}
</Alert>
)}
<div className="mb-3">
Heslo: <input
ref={refreshPassRef}
type="password"
placeholder="Zadejte heslo"
className="form-control d-inline-block"
style={{ width: 'auto', marginLeft: '10px' }}
onKeyDown={e => e.stopPropagation()}
/>
</div>
<div className="mb-3">
Typ refreshe: <select
ref={refreshTypeRef}
className="form-select d-inline-block"
style={{ width: 'auto', marginLeft: '10px' }}
defaultValue="week"
>
<option value="week">Týden</option>
<option value="day">Den</option>
</select>
</div>
<Button
variant="info"
onClick={handleRefresh}
disabled={refreshLoading}
className="mb-3"
>
{refreshLoading ? 'Refreshing...' : 'Refresh'}
</Button>
<hr />
<h4>Bankovní účet</h4>
<p>Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.<br />Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.<br /><br />Číslo účtu není ukládáno na serveru, posílá se na něj pouze za účelem vygenerování QR kódů.</p>

View File

@ -1,4 +1,5 @@
import React, { ReactNode, useContext, useEffect, useState } from "react"
import React, { ReactNode, useContext, useState } from "react"
import { useEffect } from "react"
import { useJwt } from "react-jwt";
import { deleteToken, getToken, storeToken } from "../Utils";
@ -15,7 +16,7 @@ type ContextProps = {
const authContext = React.createContext<AuthContextProps | null>(null);
export function ProvideAuth(props: Readonly<ContextProps>) {
export function ProvideAuth(props: ContextProps) {
const auth = useProvideAuth();
return <authContext.Provider value={auth}>{props.children}</authContext.Provider>
}
@ -28,7 +29,7 @@ function useProvideAuth(): AuthContextProps {
const [loginName, setLoginName] = useState<string | undefined>();
const [trusted, setTrusted] = useState<boolean | undefined>();
const [token, setToken] = useState<string | undefined>(getToken());
const { decodedToken } = useJwt(token ?? '');
const { decodedToken } = useJwt(token || '');
useEffect(() => {
if (token && token.length > 0) {

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { getEasterEgg } from "../api/EasterEggApi";
import { AuthContextProps } from "./auth";
import { EasterEgg, getEasterEgg } from "../../../types";
import { EasterEgg } from "../../../types";
export const useEasterEgg = (auth?: AuthContextProps | null): [EasterEgg | undefined, boolean] => {
const [result, setResult] = useState<EasterEgg | undefined>();
@ -10,7 +11,7 @@ export const useEasterEgg = (auth?: AuthContextProps | null): [EasterEgg | undef
async function fetchEasterEgg() {
if (auth?.login) {
setLoading(true);
const egg = (await getEasterEgg())?.data;
const egg = await getEasterEgg();
setResult(egg);
setLoading(false);
}

View File

@ -1,4 +1,5 @@
import React, { ReactNode, useContext, useEffect, useState } from "react"
import React, { ReactNode, useContext, useState } from "react"
import { useEffect } from "react"
const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number';
const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
@ -19,7 +20,7 @@ type ContextProps = {
const settingsContext = React.createContext<SettingsContextProps | null>(null);
export function ProvideSettings(props: Readonly<ContextProps>) {
export function ProvideSettings(props: ContextProps) {
const settings = useProvideSettings();
return <settingsContext.Provider value={settings}>{props.children}</settingsContext.Provider>
}
@ -44,7 +45,7 @@ function useProvideSettings(): SettingsContextProps {
}
const hideSoups = localStorage.getItem(HIDE_SOUPS_KEY);
if (hideSoups !== null) {
setHideSoups(hideSoups === 'true');
setHideSoups(hideSoups === 'true' ? true : false);
}
}, [])

View File

@ -18,3 +18,8 @@ export const SocketContext = React.createContext();
export const EVENT_CONNECT = 'connect';
export const EVENT_DISCONNECT = 'disconnect';
export const EVENT_MESSAGE = 'message';
// export const EVENT_CONFIG = 'config';
// export const EVENT_TOASTER = 'toaster';
// export const EVENT_VOTING = 'voting';
// export const EVENT_VOTE_CONFIG = 'voteSettings';
// export const EVENT_ADMIN = 'admin';

View File

@ -1,41 +0,0 @@
import { LunchChoice, Restaurant } from "../../types";
export function getRestaurantName(restaurant: Restaurant) {
switch (restaurant) {
case Restaurant.SLADOVNICKA:
return "Sladovnická";
case Restaurant.TECHTOWER:
return "TechTower";
case Restaurant.ZASTAVKAUMICHALA:
return "Zastávka u Michala";
case Restaurant.SENKSERIKOVA:
return "Šenk Šeříková";
default:
return restaurant;
}
}
export function getLunchChoiceName(location: LunchChoice) {
switch (location) {
case Restaurant.SLADOVNICKA:
return "Sladovnická";
case Restaurant.TECHTOWER:
return "TechTower";
case Restaurant.ZASTAVKAUMICHALA:
return "Zastávka u Michala";
case Restaurant.SENKSERIKOVA:
return "Šenk Šeříková";
case LunchChoice.SPSE:
return "SPŠE";
case LunchChoice.PIZZA:
return "Pizza day";
case LunchChoice.OBJEDNAVAM:
return "Budu objednávat";
case LunchChoice.NEOBEDVAM:
return "Mám vlastní/neobědvám";
case LunchChoice.ROZHODUJI:
return "Rozhoduji se";
default:
return location;
}
}

View File

@ -5,24 +5,6 @@ import 'react-toastify/dist/ReactToastify.css';
import './index.css';
import AppRoutes from './AppRoutes';
import { BrowserRouter } from 'react-router';
import { client } from '../../types/gen/client.gen';
import { getToken } from './Utils';
import { toast } from 'react-toastify';
client.setConfig({
auth: () => getToken(),
baseUrl: '/api', // openapi-ts si to z nějakého důvodu neumí převzít z api.yml
});
// Interceptor na vyhození toasteru při chybě
client.interceptors.response.use(async response => {
// TODO opravit - login je zatím výjimka, voláme ho "naprázdno" abychom zjistili, zda nás nepřihlásily trusted headers
if (!response.ok && response.url.indexOf("/login") == -1) {
const json = await response.json();
toast.error(json.error, { theme: "colored" });
}
return response;
});
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement

View File

@ -4,12 +4,12 @@ import Header from "../components/Header";
import { useAuth } from "../context/auth";
import Login from "../Login";
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils";
import { WeeklyStats, LunchChoice, getStats } from "../../../types";
import { getStats } from "../api/StatsApi";
import { WeeklyStats, LunchChoice } from "../../../types";
import Loader from "../components/Loader";
import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons";
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { getLunchChoiceName } from "../enums";
import './StatsPage.scss';
const CHART_WIDTH = 1400;
@ -43,15 +43,14 @@ export default function StatsPage() {
// Přenačtení pro zvolený týden
useEffect(() => {
if (dateRange) {
getStats({ query: { startDate: formatDate(dateRange[0]), endDate: formatDate(dateRange[1]) } }).then(response => {
setData(response.data);
});
getStats(formatDate(dateRange[0]), formatDate(dateRange[1])).then(setData);
}
}, [dateRange]);
const renderLine = (location: LunchChoice) => {
const index = Object.values(LunchChoice).indexOf(location);
return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
const key = Object.keys(LunchChoice)[index];
return <Line key={location} name={location} type="monotone" dataKey={data => data.locations[key] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
}
const handlePreviousWeek = () => {

View File

@ -1,18 +1,5 @@
#!/bin/bash
# Spustí server a klienta v samostatných panelech uvnitř stejného tmux okna.
# Pokud už daná tmux session existuje, pouze se k ní připojí.
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
SESSION="luncher"
if ! tmux has-session -t $SESSION 2>/dev/null; then
cd types && yarn openapi-ts && cd ..
tmux new-session -d -s $SESSION
tmux send-keys -t $SESSION:0 "cd $SCRIPT_DIR" Enter
tmux split-window -v
tmux send-keys -t $SESSION:0.0 "cd server && export NODE_ENV=development && yarn startReload" Enter
tmux send-keys -t $SESSION:0.1 "cd client && yarn start" Enter
fi
tmux attach-session -t $SESSION
export NODE_ENV=development
cd types && yarn install && yarn openapi-ts
cd server && yarn install && yarn start &
cd client && yarn install && yarn start &
wait

1
server/.gitignore vendored
View File

@ -1,4 +1,3 @@
/data
/dist
/resources/easterEggs
/src/gen

View File

@ -49,16 +49,16 @@ export async function downloadPizzy(mock: boolean): Promise<Pizza[]> {
const $ = load(html);
const links = $('.vypisproduktu > div > h4 > a');
const urls = [];
for (const element of links) {
if (element.name === 'a' && element.attribs?.href) {
const pizzaUrl = element.attribs?.href;
for (let i = 0; i < links.length; i++) {
if (links[i].name === 'a' && links[i].attribs?.href) {
const pizzaUrl = links[i].attribs?.href;
urls.push(buildPizzaUrl(pizzaUrl));
}
}
// Scrapneme jednotlivé pizzy
const result: Pizza[] = [];
for (const element of urls) {
const pizzaUrl = element;
for (let i = 0; i < urls.length; i++) {
const pizzaUrl = urls[i];
const pizzaHtml = await axios.get(pizzaUrl).then(res => res.data);
// Název
const name = $('.produkt > h2', pizzaHtml).first().text()

View File

@ -9,13 +9,13 @@ import { generateToken, verify } from "./auth";
import { InsufficientPermissions } from "./utils";
import { initWebsocket } from "./websocket";
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
import foodRoutes from "./routes/foodRoutes";
import votingRoutes from "./routes/votingRoutes";
import easterEggRoutes from "./routes/easterEggRoutes";
import statsRoutes from "./routes/statsRoutes";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
const ENVIRONMENT = process.env.NODE_ENV || 'production';
dotenv.config({ path: path.resolve(__dirname, `./.env.${ENVIRONMENT}`) });
// Validace nastavení JWT tokenu - nemá bez něj smysl vůbec povolit server spustit
if (!process.env.JWT_SECRET) {
@ -35,7 +35,7 @@ app.use(cors({
// Zapínatelný login přes hlavičky - pokud je zapnutý nepovolí "basicauth"
const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false;
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user';
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME || 'remote-user';
if (HTTP_REMOTE_USER_ENABLED) {
if (!process.env.HTTP_REMOTE_TRUSTED_IPS) {
throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.');
@ -54,10 +54,6 @@ app.get("/api/whoami", (req, res) => {
if (!HTTP_REMOTE_USER_ENABLED) {
res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' });
}
if(process.env.ENABLE_HEADERS_LOGGING === 'yes'){
delete req.headers["cookie"]
console.log(req.headers)
}
res.send(req.header(HTTP_REMOTE_USER_HEADER_NAME));
})
@ -65,11 +61,11 @@ app.post("/api/login", (req, res) => {
if (HTTP_REMOTE_USER_ENABLED) { // je rovno app.enabled('trust proxy')
// Autentizace pomocí trusted headers
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
//const remoteName = req.header('remote-name');
if (remoteUser && remoteUser.length > 0 ) {
res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true));
const remoteName = req.header('remote-name');
if (remoteUser && remoteUser.length > 0 && remoteName && remoteName.length > 0) {
res.status(200).json(generateToken(Buffer.from(remoteName, 'latin1').toString(), true));
} else {
throw Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??");
throw Error("Tohle nema nastat nekdo neco dela spatne.");
}
} else {
// Klasická autentizace loginem
@ -83,6 +79,7 @@ app.post("/api/login", (req, res) => {
// TODO dočasné řešení - QR se zobrazuje přes <img>, nemáme sem jak dostat token
app.get("/api/qr", (req, res) => {
// const login = getLogin(parseToken(req));
if (!req.query?.login) {
throw Error("Nebyl předán login");
}
@ -96,22 +93,16 @@ app.get("/api/qr", (req, res) => {
// ----------------------------------------------------
// Přeskočení auth pro refresh dat xd
app.use("/api/food/refresh", refreshMetoda);
/** Middleware ověřující JWT token */
app.use("/api/", (req, res, next) => {
if (HTTP_REMOTE_USER_ENABLED) {
// Autentizace pomocí trusted headers
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
if(process.env.ENABLE_HEADERS_LOGGING === 'yes'){
delete req.headers["cookie"]
console.log(req.headers)
}
if (remoteUser && remoteUser.length > 0) {
const remoteName = Buffer.from(remoteUser, 'latin1').toString();
const userHeader = req.header(HTTP_REMOTE_USER_HEADER_NAME);
const nameHeader = req.header('remote-name');
const emailHeader = req.header('remote-email');
if (userHeader !== undefined && nameHeader !== undefined) {
const remoteName = Buffer.from(nameHeader, 'latin1').toString();
if (ENVIRONMENT !== "production") {
console.log("Tvuj username: %s.", remoteName);
console.log("Tvuj username, name a email: %s, %s, %s.", userHeader, remoteName, emailHeader);
}
}
}
@ -157,8 +148,8 @@ app.use((err: any, req: any, res: any, next: any) => {
next();
});
const PORT = process.env.PORT ?? 3001;
const HOST = process.env.HOST ?? '0.0.0.0';
const PORT = process.env.PORT || 3001;
const HOST = process.env.HOST || '0.0.0.0';
server.listen(PORT, () => {
console.log(`Server listening on ${HOST}, port ${PORT}`);

View File

@ -1,4 +1,4 @@
import { WeeklyStats, LunchChoice } from "../../types/gen/types.gen";
import { WeeklyStats, LunchChoice } from "../../types";
// Mockovací data pro podporované podniky, na jeden týden
const MOCK_DATA = {

View File

@ -1,13 +1,59 @@
/** Notifikace */
import axios from 'axios';
import dotenv from 'dotenv';
import path from 'path';
import { getClientData, getToday } from "./service";
import { getUsersByLocation, getHumanTime } from "./utils";
import getStorage from "./storage";
import { NotifikaceData, NotifikaceInput } from '../../types';
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
const storage = getStorage();
const ENVIRONMENT = process.env.NODE_ENV || 'production'
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
// const gotifyDataRaw = process.env.GOTIFY_SERVERS_AND_KEYS || "{}";
// const gotifyData: GotifyServer[] = JSON.parse(gotifyDataRaw);
// export const gotifyCall = async (data: NotififaceInput, gotifyServers?: GotifyServer[]): Promise<any[]> => {
// if (!Array.isArray(gotifyServers)) {
// return []
// }
// const urls = gotifyServers.flatMap(gotifyServer =>
// gotifyServer.api_keys.map(apiKey => `${gotifyServer.server}/message?token=${apiKey}`));
//
// const dataPayload = {
// title: "Luncher",
// message: `${data.udalost} - spustil:${data.user}`,
// priority: 7,
// };
//
// const headers = { "Content-Type": "application/json" };
//
// const promises = urls.map(url =>
// axios.post(url, dataPayload, { headers }).then(response => {
// response.data = {
// success: true,
// message: "Notifikace doručena",
// };
// return response;
// }).catch(error => {
// if (axios.isAxiosError(error)) {
// const axiosError = error as AxiosError;
// if (axiosError.response) {
// axiosError.response.data = {
// success: false,
// message: "fail",
// };
// console.log(error)
// return axiosError.response;
// }
// }
// // Handle unknown error without a response
// console.log(error, "unknown error");
// })
// );
// return promises;
// };
export const ntfyCall = async (data: NotifikaceInput) => {
const url = process.env.NTFY_HOST
const username = process.env.NTFY_USERNAME;

View File

@ -4,7 +4,7 @@ import { generateQr } from "./qr";
import getStorage from "./storage";
import { downloadPizzy } from "./chefie";
import { getClientData, getToday, initIfNeeded } from "./service";
import { Pizza, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum } from "../../types/gen/types.gen";
import { Pizza, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum } from "../../types";
const storage = getStorage();
@ -96,7 +96,9 @@ export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize
totalPrice: 0,
hasQr: false,
}
clientData.pizzaDay.orders ??= [];
if (!clientData.pizzaDay.orders) {
clientData.pizzaDay.orders = [];
}
clientData.pizzaDay.orders.push(order);
}
const pizzaOrder: PizzaVariant = {
@ -105,7 +107,9 @@ export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize
size: size.size,
price: size.price,
}
order.pizzaList ??= [];
if (!order.pizzaList) {
order.pizzaList = [];
}
order.pizzaList.push(pizzaOrder);
order.totalPrice += pizzaOrder.price;
await storage.setData(today, clientData);
@ -124,11 +128,11 @@ export async function removePizzaOrder(login: string, pizzaOrder: PizzaVariant)
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
const orderIndex = clientData.pizzaDay.orders!.findIndex(o => o.customer === login);
const orderIndex = clientData.pizzaDay!.orders!.findIndex(o => o.customer === login);
if (orderIndex < 0) {
throw Error("Nebyly nalezeny žádné objednávky pro uživatele " + login);
}
const order = clientData.pizzaDay.orders![orderIndex];
const order = clientData.pizzaDay!.orders![orderIndex];
const index = order.pizzaList!.findIndex(o => o.name === pizzaOrder.name && o.size === pizzaOrder.size);
if (index < 0) {
throw Error("Objednávka s danými parametry nebyla nalezena");
@ -304,7 +308,7 @@ export async function updatePizzaFee(login: string, targetLogin: string, text?:
targetOrder.fee = { text, price };
}
// Přepočet ceny
targetOrder.totalPrice = targetOrder.pizzaList.reduce((price, pizzaOrder) => price + pizzaOrder.price, 0) + (targetOrder.fee?.price ?? 0);
targetOrder.totalPrice = targetOrder.pizzaList.reduce((price, pizzaOrder) => price + pizzaOrder.price, 0) + (targetOrder.fee?.price || 0);
await storage.setData(today, clientData);
return clientData;
}

View File

@ -2,7 +2,7 @@ import axios from "axios";
import { load } from 'cheerio';
import { getMenuSladovnickaMock, getMenuTechTowerMock, getMenuUMotlikuMock, getMenuZastavkaUmichalaMock, getMenuSenkSerikovaMock } from "./mock";
import { formatDate } from "./utils";
import { Food } from "../../types/gen/types.gen";
import { Food } from "../../types";
// Fráze v názvech jídel, které naznačují že se jedná o polévku
const SOUP_NAMES = [
@ -23,7 +23,7 @@ const SOUP_NAMES = [
const DAYS_IN_WEEK = ['pondělí', 'úterý', 'středa', 'čtvrtek', 'pátek', 'sobota', 'neděle'];
// URL na týdenní menu jednotlivých restaurací
const SLADOVNICKA_URL = 'https://sladovnicka.unasplzenchutna.cz/cz/#denni-nabidka';
const SLADOVNICKA_URL = 'https://sladovnicka.unasplzenchutna.cz/cz/denni-nabidka';
const U_MOTLIKU_URL = 'https://www.umotliku.cz/menu';
const TECHTOWER_URL = 'https://www.equifarm.cz/restaurace-techtower';
const ZASTAVKAUMICHALA_URL = 'https://www.zastavkaumichala.cz';
@ -78,65 +78,82 @@ export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = f
const html = await getHtml(SLADOVNICKA_URL);
const $ = load(html);
const menuContentElements = $('#daily-menu-content-list').children('[id^="daily-menu-content-"]');
// Prozatím předpokládáme, že budou mít vždy elementy pro všech 5 dní v týdnu, i pokud bude zavřeno
if (menuContentElements.length < 5) {
throw Error("Neočekávaný počet dní v menu Sladovnické: " + menuContentElements.length + ", očekáváno 5 (možná je některý den zavřeno?)");
}
const list = $('ul.tab-links').children();
const result: Food[][] = [];
for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
const dayChildren = $(menuContentElements[dayIndex]).children();
// Prozatím předpokládáme, že budou mít vždy polévku a hlavní jídla
if (dayChildren.length < 2) {
throw Error("Neočekávaný počet children v menu Sladovnické pro den " + dayIndex + ": " + dayChildren.length + ", očekávány alespoň 2 (polévka a hlavní jídlo)");
const currentDate = new Date(firstDayOfWeek);
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
const searchedDayText = `${currentDate.getDate()}.${currentDate.getMonth() + 1}.${capitalize(DAYS_IN_WEEK[dayIndex])}`;
// Najdeme index pro vstupní datum (např. při svátcích bude posunutý)
// TODO validovat, že vstupní datum je v aktuálním týdnu
// TODO tenhle způsob je zbytečně komplikovaný - stačilo by hledat rovnou v div.tab-content, protože každý den tam má datum taky (akorát je print-only)
let index = undefined;
list.each((i, dayRow) => {
const rowText = $(dayRow).first().text().trim();
if (rowText === searchedDayText) {
index = i;
return;
}
})
if (index === undefined) {
// Pravděpodobně svátek, nebo je zavřeno
result[dayIndex] = [{
amount: undefined,
name: "Pro daný den nebyla nalezena denní nabídka",
price: "",
isSoup: false,
}];
continue;
}
// Parsování polévky
const soupElement = dayChildren.get(0);
const soupTable = $(soupElement).find('table tbody tr');
const soupCells = soupTable.children('td');
// Dle dohledaného indexu najdeme správný tabpanel
const rows = $('div.tab-content').children();
if (index >= rows.length) {
throw Error("V HTML nebyl nalezen řádek menu pro index " + index);
}
const tabPanel = $(rows.get(index));
// Opětovná validace, že daný tabpanel je pro vstupní datum
const headers = tabPanel.find('h2');
if (headers.length !== 3) {
throw Error("Neočekávaný počet elementů h2 v menu pro datum " + searchedDayText + ", očekávány 3, ale nalezeno bylo " + headers.length);
}
const dayText = $(headers.get(0)).text().trim();
if (dayText !== searchedDayText) {
throw Error("Neočekávaný datum na řádce nalezeného dne: '" + dayText + "', ale očekáváno bylo '" + searchedDayText + "'");
}
// V tabpanelu očekáváme dvě tabulky - pro polévku a pro hlavní jídlo
const tables = tabPanel.find('table');
if (tables.length !== 2) {
throw Error("Neočekávaný počet tabulek na řádce nalezeného dne: " + tables.length + ", ale očekávány byly 2");
}
const currentDayFood: Food[] = [];
// Polévka - div -> table -> tbody -> tr -> 3x td
const soupCells = $(tables.get(0)).children().first().children().first().children();
if (soupCells.length !== 3) {
throw Error("Neočekávaný počet buněk v tabulce polévky: " + soupCells.length + ", ale očekávány byly 3");
}
const soupAmount = sanitizeText($(soupCells.get(0)).text());
const soupName = sanitizeText($(soupCells.get(1)).text());
const soupPrice = sanitizeText($(soupCells.get(2)).text().replace(' ', '\xA0'));
// Parsování hlavních jídel
const mainCourseElement = dayChildren.get(1);
const mainCourseTable = $(mainCourseElement).find('table tbody');
const mainCourseRows = mainCourseTable.children('tr');
const currentDayFood: Food[] = [];
// Přidáme polévku do seznamu jídel
currentDayFood.push({
amount: soupAmount,
name: soupName,
price: soupPrice,
amount: sanitizeText($(soupCells.get(0)).text()),
name: sanitizeText($(soupCells.get(1)).text()),
price: sanitizeText($(soupCells.get(2)).text().replace(' ', '\xA0')),
isSoup: true,
});
// Projdeme všechny řádky hlavních jídel
mainCourseRows.each((i, row) => {
const cells = $(row).children('td');
const amount = sanitizeText($(cells.get(0)).text());
const name = sanitizeText($(cells.get(1)).text());
const price = sanitizeText($(cells.get(2)).text().replace(' ', '\xA0'));
// Přeskočíme prázdné řádky (první řádek může být prázdný)
if (name.trim().length > 0) {
currentDayFood.push({
amount,
name,
price,
isSoup: false,
});
// Hlavní jídla - div -> table -> tbody -> 3x tr
const mainCourseRows = $(tables.get(1)).children().first().children();
mainCourseRows.each((i, foodRow) => {
const foodCells = $(foodRow).children();
if (foodCells.length !== 3) {
throw Error("Neočekávaný počet buněk v řádku jídla: " + foodCells.length + ", ale očekávány byly 3");
}
});
currentDayFood.push({
amount: sanitizeText($(foodCells.get(0)).text()),
name: sanitizeText($(foodCells.get(1)).text()),
price: sanitizeText($(foodCells.get(2)).text().replace(' ', '\xA0')),
isSoup: false,
});
})
result[dayIndex] = currentDayFood;
}
return result;
@ -275,7 +292,7 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
const result: Food[][] = [];
// TODO validovat, že v textu nalezeného <font> je rozsah, do kterého spadá vstupní datum
const siblings = secondTry ? $($(font).parent().parent().parent().next('h3').children('font')[0]).children('p') : $(font).parent().parent().siblings();
const siblings = secondTry ? $(font).parent().siblings() : $(font).parent().parent().siblings();
let parsing = false;
let currentDayIndex = 0;
for (let i = 0; i < siblings.length; i++) {
@ -300,7 +317,9 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
price = `${split.slice(1)[0]}\xA0Kč`
name = split[0].replace('•', '');
}
result[currentDayIndex] ??= [];
if (result[currentDayIndex] == null) {
result[currentDayIndex] = [];
}
result[currentDayIndex].push({
amount: '-',
name,
@ -324,8 +343,7 @@ export const getMenuZastavkaUmichala = async (firstDayOfWeek: Date, mock: boolea
return getMenuZastavkaUmichalaMock();
}
const today = new Date();
today.setHours(0,0,0,0);
const nowDate = new Date().getDate();
const headers = {
"Cookie": "_nss=1; PHPSESSID=9e37de17e0326b0942613d6e67a30e69",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36",
@ -334,18 +352,18 @@ export const getMenuZastavkaUmichala = async (firstDayOfWeek: Date, mock: boolea
for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
const currentDate = new Date(firstDayOfWeek);
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
currentDate.setHours(0,0,0,0);
if (currentDate < today || (currentDate.getTime() === today.getTime() && new Date().getHours() >= 14)) {
if (currentDate.getDate() < nowDate || (currentDate.getDate() === nowDate && new Date().getHours() >= 14)) {
result[dayIndex] = [{
amount: undefined,
name: "Pro tento den není uveřejněna nabídka jídel",
price: "",
isSoup: false,
}];
continue;
} else {
const url = (currentDate.getTime() === today.getTime())
? ZASTAVKAUMICHALA_URL
: ZASTAVKAUMICHALA_URL + '/?do=dailyMenu-changeDate&dailyMenu-dateString=' + formatDate(currentDate, 'DD.MM.YYYY');
const url = (currentDate.getDate() === nowDate) ?
ZASTAVKAUMICHALA_URL : ZASTAVKAUMICHALA_URL + '/?do=dailyMenu-changeDate&dailyMenu-dateString=' + formatDate(currentDate, 'DD.MM.YYYY');
const html = await axios.get(url, {
headers,
}).then(res => res.data).then(content => content);
@ -385,13 +403,11 @@ export const getMenuSenkSerikova = async (firstDayOfWeek: Date, mock: boolean =
}).then(res => decoder.decode(new Uint8Array(res.data))).then(content => content);
const $ = load(html);
const today = new Date();
today.setHours(0,0,0,0);
const nowDate = new Date().getDate();
const currentDate = new Date(firstDayOfWeek);
const result: Food[][] = [];
let dayIndex = 0;
currentDate.setHours(0,0,0,0);
while (currentDate < today) {
while (currentDate.getDate() < nowDate) {
result[dayIndex] = [{
amount: undefined,
name: "Pro tento den není uveřejněna nabídka jídel",

View File

@ -1,9 +1,9 @@
import express, { NextFunction } from "express";
import { getLogin } from "../auth";
import { getLogin, getTrusted } from "../auth";
import { parseToken } from "../utils";
import path from "path";
import fs from "fs";
import { EasterEgg } from "../../../types/gen/types.gen";
import { EasterEgg } from "../../../types";
const EASTER_EGGS_JSON_PATH = path.join(__dirname, "../../.easter-eggs.json");
const IMAGES_PATH = '../../resources/easterEggs';
@ -34,11 +34,16 @@ function generateUrl() {
*/
function getEasterEggImage(req: any, res: any, next: NextFunction) {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
try {
if (login in easterEggs) {
const imagePath = easterEggs[login][Math.floor(Math.random() * easterEggs[login].length)].path;
res.sendFile(path.join(__dirname, IMAGES_PATH, imagePath));
return;
// TODO vrátit!
// if (trusted) {
if (true) {
if (login in easterEggs) {
const imagePath = easterEggs[login][Math.floor(Math.random() * easterEggs[login].length)].path;
res.sendFile(path.join(__dirname, IMAGES_PATH, imagePath));
return;
}
}
res.sendStatus(404);
} catch (e: any) { next(e) }
@ -119,7 +124,7 @@ let easterEggs: EasterEggsJson;
if (fs.existsSync(EASTER_EGGS_JSON_PATH)) {
const content = fs.readFileSync(EASTER_EGGS_JSON_PATH, 'utf-8');
easterEggs = JSON.parse(content);
for (const [_, eggs] of Object.entries(easterEggs)) {
for (const [key, eggs] of Object.entries(easterEggs)) {
for (const easterEgg of eggs) {
const url = generateUrl();
easterEgg.url = url;
@ -133,11 +138,16 @@ if (fs.existsSync(EASTER_EGGS_JSON_PATH)) {
// Získání náhodného easter eggu pro přihlášeného uživatele
router.get("/", async (req, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
try {
if (easterEggs && login in easterEggs) {
const randomEasterEgg = easterEggs[login][Math.floor(Math.random() * easterEggs[login].length)];
const { path, startOffset, endOffset, ...strippedEasterEgg } = randomEasterEgg; // Path klient k ničemu nepotřebuje a nemá ho znát
return res.status(200).json({ ...strippedEasterEgg, ...getRandomPosition(startOffset, endOffset) });
// TODO vrátit!
// if (trusted) {
if (true) {
if (easterEggs && login in easterEggs) {
const randomEasterEgg = easterEggs[login][Math.floor(Math.random() * easterEggs[login].length)];
const { path, startOffset, endOffset, ...strippedEasterEgg } = randomEasterEgg; // Path klient k ničemu nepotřebuje a nemá ho znát
return res.status(200).json({ ...strippedEasterEgg, ...getRandomPosition(startOffset, endOffset) });
}
}
return res.status(200).send();
} catch (e: any) { next(e) }

View File

@ -1,51 +1,11 @@
import express, { Request, Response } from "express";
import express, { Request } from "express";
import { getLogin, getTrusted } from "../auth";
import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote, fetchRestaurantWeekMenuData, saveRestaurantWeekMenu } from "../service";
import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils";
import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote } from "../service";
import { getDayOfWeekIndex, parseToken } from "../utils";
import { getWebsocket } from "../websocket";
import { callNotifikace } from "../notifikace";
import { AddChoiceData, ChangeDepartureTimeData, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen";
// RateLimit na refresh endpoint
interface RateLimitEntry {
count: number;
resetTime: number;
}
const rateLimits: Record<string, RateLimitEntry> = {};
const RATE_LIMIT = 1; // maximální počet požadavků za minutu
const RATE_LIMIT_WINDOW = 30 * 60 * 1000; // je to v ms (x * 1min)
// Kontrola ratelimitu
function checkRateLimit(key: string, limit: number = RATE_LIMIT): boolean {
const now = Date.now();
// Vyčištění starých záznamů
Object.keys(rateLimits).forEach(k => {
if (rateLimits[k].resetTime < now) {
delete rateLimits[k];
}
});
// Kontrola, že záznam existuje a platí
if (rateLimits[key] && rateLimits[key].resetTime > now) {
// Záznam platí a kontroluje se limit
if (rateLimits[key].count >= limit) {
return false; // Překročen limit
}
// ++ xd
rateLimits[key].count++;
return true;
} else {
// + klic
rateLimits[key] = {
count: 1,
resetTime: now + RATE_LIMIT_WINDOW
};
return true;
}
}
import { AddChoiceRequest, ChangeDepartureTimeRequest, IDayIndex, RemoveChoiceRequest, RemoveChoicesRequest, UpdateNoteRequest } from "../../../types";
import { UdalostEnum } from "../../../types";
/**
* Ověří a vrátí index dne v týdnu z požadavku, za předpokladu, že byl předán, a je zároveň
@ -54,7 +14,7 @@ function checkRateLimit(key: string, limit: number = RATE_LIMIT): boolean {
* @param req request
* @returns index dne v týdnu
*/
const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] | UpdateNoteData["body"]>) => {
const parseValidateFutureDayIndex = (req: Request<{}, any, IDayIndex>) => {
if (req.body.dayIndex == null) {
throw Error(`Nebyl předán index dne v týdnu.`);
}
@ -71,7 +31,7 @@ const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"]
const router = express.Router();
router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, res, next) => {
router.post("/addChoice", async (req: Request<{}, any, AddChoiceRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
@ -91,7 +51,7 @@ router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, r
} catch (e: any) { next(e) }
});
router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["body"]>, res, next) => {
router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
@ -111,7 +71,7 @@ router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["bo
} catch (e: any) { next(e) }
});
router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body"]>, res, next) => {
router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
@ -131,7 +91,7 @@ router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body
} catch (e: any) { next(e) }
});
router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>, res, next) => {
router.post("/updateNote", async (req: Request<{}, any, UpdateNoteRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
const note = req.body.note;
@ -155,7 +115,7 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
} catch (e: any) { next(e) }
});
router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDepartureTimeData["body"]>, res, next) => {
router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDepartureTimeRequest>, res, next) => {
const login = getLogin(parseToken(req));
let date = undefined;
if (req.body.dayIndex != null) {
@ -182,85 +142,4 @@ router.post("/jdemeObed", async (req, res, next) => {
} catch (e: any) { next(e) }
});
// /api/food/refresh?type=week&heslo=docasnyheslo
export const refreshMetoda = async (req: Request, res: Response) => {
const { type, heslo } = req.query as { type?: string; heslo?: string };
if (heslo !== "docasnyheslo" && heslo !== "tohleheslopavelnesmizjistit123") {
return res.status(403).json({ error: "Neplatné heslo" });
}
if (!checkRateLimit("refresh") && heslo !== "tohleheslopavelnesmizjistit123") {
return res.status(429).json({ error: "Refresh už se zavolal, chvíli počkej :))" });
}
if (type !== "week" && type !== "day") {
return res.status(400).json({ error: "Neznámý typ refresh" });
}
if (type === "day") {
return res.status(400).json({ error: "ještě neumim TODO..." });
}
try {
// Pro všechny restaurace refreshni menu na aktuální týden
const restaurants = ["SLADOVNICKA", "TECHTOWER", "ZASTAVKAUMICHALA", "SENKSERIKOVA"] as const;
const firstDay = getFirstWorkDayOfWeek(getToday());
const results: Record<string, any> = {};
const successfulRestaurants: string[] = [];
const failedRestaurants: string[] = [];
// Nejdříve načíst všechna data bez ukládání
for (const rest of restaurants) {
try {
const weekData = await fetchRestaurantWeekMenuData(rest, firstDay);
results[rest] = weekData;
// Kontrola validity dat
if (weekData && weekData.length > 0 &&
weekData.some(dayMenu => dayMenu && dayMenu.length > 0)) {
successfulRestaurants.push(rest);
} else {
failedRestaurants.push(rest);
results[rest] = { error: "Žádná validní data" };
}
} catch (error) {
failedRestaurants.push(rest);
results[rest] = { error: `Chyba při načítání: ${error}` };
}
}
// Pokud se nepodařilo načíst žádnou restauraci
if (successfulRestaurants.length === 0) {
return res.status(400).json({
error: "Nepodařilo se získat validní data z žádné restaurace",
failed: failedRestaurants,
results: results
});
}
// Uložit pouze validní data
for (const rest of successfulRestaurants) {
try {
await saveRestaurantWeekMenu(rest as any, firstDay, results[rest]);
} catch (error) {
console.error(`Chyba při ukládání dat pro ${rest}:`, error);
}
}
// Připravit odpověď
const response: any = {
ok: true,
refreshed: results,
successful: successfulRestaurants
};
if (failedRestaurants.length > 0) {
response.warning = `Nepodařilo se načíst: ${failedRestaurants.join(', ')}`;
response.failed = failedRestaurants;
}
res.status(200).json(response);
} catch (e: any) {
res.status(500).json({ error: e?.message || "Chyba při refreshi" });
}
}
router.get("/refresh", refreshMetoda);
export default router;

View File

@ -3,7 +3,7 @@ import { getLogin } from "../auth";
import { createPizzaDay, deletePizzaDay, getPizzaList, addPizzaOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee } from "../pizza";
import { parseToken } from "../utils";
import { getWebsocket } from "../websocket";
import { AddPizzaData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types";
import { AddPizzaRequest, FinishDeliveryRequest, RemovePizzaRequest, UpdatePizzaDayNoteRequest, UpdatePizzaFeeRequest } from "../../../types";
const router = express.Router();
@ -22,7 +22,7 @@ router.post("/delete", async (req: Request<{}, any, undefined>, res) => {
getWebsocket().emit("message", data);
});
router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) => {
router.post("/add", async (req: Request<{}, any, AddPizzaRequest>, res) => {
const login = getLogin(parseToken(req));
if (isNaN(req.body?.pizzaIndex)) {
throw Error("Nebyl předán index pizzy");
@ -47,7 +47,7 @@ router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) =>
res.status(200).json({});
});
router.post("/remove", async (req: Request<{}, any, RemovePizzaData["body"]>, res) => {
router.post("/remove", async (req: Request<{}, any, RemovePizzaRequest>, res) => {
const login = getLogin(parseToken(req));
if (!req.body?.pizzaOrder) {
throw Error("Nebyla předána objednávka");
@ -78,14 +78,14 @@ router.post("/finishOrder", async (req: Request<{}, any, undefined>, res) => {
res.status(200).json({});
});
router.post("/finishDelivery", async (req: Request<{}, any, FinishDeliveryData["body"]>, res) => {
router.post("/finishDelivery", async (req: Request<{}, any, FinishDeliveryRequest>, res) => {
const login = getLogin(parseToken(req));
const data = await finishPizzaDelivery(login, req.body.bankAccount, req.body.bankAccountHolder);
getWebsocket().emit("message", data);
res.status(200).json({});
});
router.post("/updatePizzaDayNote", async (req: Request<{}, any, UpdatePizzaDayNoteData["body"]>, res, next) => {
router.post("/updatePizzaDayNote", async (req: Request<{}, any, UpdatePizzaDayNoteRequest>, res, next) => {
const login = getLogin(parseToken(req));
try {
if (req.body.note && req.body.note.length > 70) {
@ -97,7 +97,7 @@ router.post("/updatePizzaDayNote", async (req: Request<{}, any, UpdatePizzaDayNo
} catch (e: any) { next(e) }
});
router.post("/updatePizzaFee", async (req: Request<{}, any, UpdatePizzaFeeData["body"]>, res, next) => {
router.post("/updatePizzaFee", async (req: Request<{}, any, UpdatePizzaFeeRequest>, res, next) => {
const login = getLogin(parseToken(req));
if (!req.body.login) {
return res.status(400).json({ error: "Nebyl předán login cílového uživatele" });

View File

@ -2,7 +2,7 @@ import express, { Request, Response } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getStats } from "../stats";
import { WeeklyStats } from "../../../types/gen/types.gen";
import { WeeklyStats } from "../../../types";
const router = express.Router();

View File

@ -1,18 +1,19 @@
import express, { Request } from "express";
import express, { Request, Response } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getUserVotes, updateFeatureVote } from "../voting";
import { GetVotesData, UpdateVoteData } from "../../../types";
import { UpdateFeatureVoteRequest } from "../../../types";
import { FeatureRequest } from "../../../types";
const router = express.Router();
router.get("/getVotes", async (req: Request<{}, any, GetVotesData["body"]>, res) => {
router.get("/getVotes", async (req: Request<{}, any, undefined>, res: Response<FeatureRequest[]>) => {
const login = getLogin(parseToken(req));
const data = await getUserVotes(login);
res.status(200).json(data);
});
router.post("/updateVote", async (req: Request<{}, any, UpdateVoteData["body"]>, res, next) => {
router.post("/updateVote", async (req: Request<{}, any, UpdateFeatureVoteRequest>, res, next) => {
const login = getLogin(parseToken(req));
if (req.body?.option == null || req.body?.active == null) {
res.status(400).json({ error: "Chybné parametry volání" });

View File

@ -1,8 +1,8 @@
import { InsufficientPermissions, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getHumanDate, getIsWeekend, getWeekNumber } from "./utils";
import getStorage from "./storage";
import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova } from "./restaurants";
import { getMenuSladovnicka, getMenuTechTower, getMenuUMotliku, getMenuZastavkaUmichala, getMenuSenkSerikova } from "./restaurants";
import { getTodayMock } from "./mock";
import { ClientData, DepartureTime, LunchChoice, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
import { ClientData, DepartureTime, LunchChoice, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types";
const storage = getStorage();
const MENU_PREFIX = 'menu';
@ -76,107 +76,15 @@ async function getMenu(date: Date): Promise<WeekMenu | undefined> {
}
// TODO přesun do restaurants.ts
/**
* Načte menu dané restaurace pro celý týden bez ukládání do storage.
* Používá se pro validaci dat před uložením.
*
* @param restaurant restaurace
* @param firstDay první pracovní den týdne
* @returns pole menu pro jednotlivé dny týdne
*/
export async function fetchRestaurantWeekMenuData(restaurant: Restaurant, firstDay: Date): Promise<any[]> {
return await fetchRestaurantWeekMenu(restaurant, firstDay);
}
/**
* Uloží týdenní menu restaurace do storage.
*
* @param restaurant restaurace
* @param date datum z týdne, pro který ukládat menu
* @param weekData data týdenního menu
*/
export async function saveRestaurantWeekMenu(restaurant: Restaurant, date: Date, weekData: any[]): Promise<void> {
const now = new Date().getTime();
let weekMenu = await getMenu(date);
weekMenu ??= [{}, {}, {}, {}, {}];
// Inicializace struktury pro restauraci
for (let i = 0; i < 5; i++) {
weekMenu[i] ??= {};
weekMenu[i][restaurant] ??= {
lastUpdate: now,
closed: false,
food: [],
};
}
// Uložení dat pro všechny dny
for (let i = 0; i < weekData.length && i < weekMenu.length; i++) {
weekMenu[i][restaurant]!.food = weekData[i];
weekMenu[i][restaurant]!.lastUpdate = now;
// Detekce uzavření pro každou restauraci
switch (restaurant) {
case 'SLADOVNICKA':
if (weekData[i].length === 1 && weekData[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'TECHTOWER':
if (weekData[i]?.length === 1 && weekData[i][0].name.toLowerCase() === 'svátek') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'ZASTAVKAUMICHALA':
if (weekData[i]?.length === 1 && weekData[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'SENKSERIKOVA':
if (weekData[i]?.length === 1 && weekData[i][0].name === 'Pro tento den nebylo zadáno menu.') {
weekMenu[i][restaurant]!.closed = true;
}
break;
}
}
// Uložení do storage
await storage.setData(getMenuKey(date), weekMenu);
}
/**
* Načte menu dané restaurace pro celý týden bez ukládání do storage.
*
* @param restaurant restaurace
* @param firstDay první pracovní den týdne
* @returns pole menu pro jednotlivé dny týdne
*/
async function fetchRestaurantWeekMenu(restaurant: Restaurant, firstDay: Date): Promise<any[]> {
const mock = process.env.MOCK_DATA === 'true';
switch (restaurant) {
case 'SLADOVNICKA':
return await getMenuSladovnicka(firstDay, mock);
case 'TECHTOWER':
return await getMenuTechTower(firstDay, mock);
case 'ZASTAVKAUMICHALA':
return await getMenuZastavkaUmichala(firstDay, mock);
case 'SENKSERIKOVA':
return await getMenuSenkSerikova(firstDay, mock);
default:
throw new Error(`Nepodporovaná restaurace: ${restaurant}`);
}
}
/**
* Vrátí menu dané restaurace pro předaný den.
* Pokud neexistuje, provede stažení menu pro příslušný týden a uložení do DB.
*
* @param restaurant restaurace
* @param date datum, ke kterému získat menu
* @param forceRefresh příznak vynuceného obnovení
* @param mock příznak, zda chceme pouze mock data
*/
export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, forceRefresh = false): Promise<RestaurantDayMenu> {
export async function getRestaurantMenu(restaurant: keyof typeof Restaurant, date?: Date): Promise<RestaurantDayMenu> {
const usedDate = date ?? getToday();
const dayOfWeekIndex = getDayOfWeekIndex(usedDate);
const now = new Date().getTime();
@ -189,56 +97,93 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, for
}
let weekMenu = await getMenu(usedDate);
weekMenu ??= [{}, {}, {}, {}, {}];
for (let i = 0; i < 5; i++) {
weekMenu[i] ??= {};
weekMenu[i][restaurant] ??= {
lastUpdate: now,
closed: false,
food: [],
};
if (weekMenu == null) {
weekMenu = [{}, {}, {}, {}, {}];
}
if (forceRefresh || (!weekMenu[dayOfWeekIndex][restaurant]?.food?.length && !weekMenu[dayOfWeekIndex][restaurant]?.closed)) {
const firstDay = getFirstWorkDayOfWeek(usedDate);
try {
const restaurantWeekFood = await fetchRestaurantWeekMenu(restaurant, firstDay);
// Aktualizace menu pro všechny dny
for (let i = 0; i < restaurantWeekFood.length && i < weekMenu.length; i++) {
weekMenu[i][restaurant]!.food = restaurantWeekFood[i];
weekMenu[i][restaurant]!.lastUpdate = now;
// Detekce uzavření pro každou restauraci
switch (restaurant) {
case 'SLADOVNICKA':
if (restaurantWeekFood[i].length === 1 && restaurantWeekFood[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'TECHTOWER':
if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name.toLowerCase() === 'svátek') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'ZASTAVKAUMICHALA':
if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'SENKSERIKOVA':
if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name === 'Pro tento den nebylo zadáno menu.') {
weekMenu[i][restaurant]!.closed = true;
}
break;
}
}
// Uložení do storage
await storage.setData(getMenuKey(usedDate), weekMenu);
} catch (e: any) {
console.error(`Selhalo načtení jídel pro podnik ${restaurant}`, e);
for (let i = 0; i < 5; i++) {
if (weekMenu[i] == null) {
weekMenu[i] = {};
}
if (weekMenu[i][restaurant] == null) {
weekMenu[i][restaurant] = {
lastUpdate: now,
closed: false,
food: [],
};
}
}
if (!weekMenu[dayOfWeekIndex][restaurant]?.food?.length) {
const firstDay = getFirstWorkDayOfWeek(usedDate);
const mock = process.env.MOCK_DATA === 'true';
switch (restaurant) {
case 'SLADOVNICKA':
try {
const sladovnickaFood = await getMenuSladovnicka(firstDay, mock);
for (let i = 0; i < sladovnickaFood.length; i++) {
weekMenu[i][restaurant]!.food = sladovnickaFood[i];
// Velice chatrný a nespolehlivý způsob detekce uzavření...
if (sladovnickaFood[i].length === 1 && sladovnickaFood[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') {
weekMenu[i][restaurant]!.closed = true;
}
}
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik Sladovnická", e);
}
break;
// case 'UMOTLIKU':
// try {
// const uMotlikuFood = await getMenuUMotliku(firstDay, mock);
// for (let i = 0; i < uMotlikuFood.length; i++) {
// menus[i][restaurant]!.food = uMotlikuFood[i];
// if (uMotlikuFood[i].length === 1 && uMotlikuFood[i][0].name.toLowerCase() === 'zavřeno') {
// menus[i][restaurant]!.closed = true;
// }
// }
// } catch (e: any) {
// console.error("Selhalo načtení jídel pro podnik U Motlíků", e);
// }
// break;
case 'TECHTOWER':
try {
const techTowerFood = await getMenuTechTower(firstDay, mock);
for (let i = 0; i < techTowerFood.length; i++) {
weekMenu[i][restaurant]!.food = techTowerFood[i];
if (techTowerFood[i]?.length === 1 && techTowerFood[i][0].name.toLowerCase() === 'svátek') {
weekMenu[i][restaurant]!.closed = true;
}
}
break;
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik TechTower", e);
}
case 'ZASTAVKAUMICHALA':
try {
const zastavkaUmichalaFood = await getMenuZastavkaUmichala(firstDay, mock);
for (let i = 0; i < zastavkaUmichalaFood.length; i++) {
weekMenu[i][restaurant]!.food = zastavkaUmichalaFood[i];
if (zastavkaUmichalaFood[i]?.length === 1 && zastavkaUmichalaFood[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') {
weekMenu[i][restaurant]!.closed = true;
}
}
break;
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik Zastávka u Michala", e);
}
case 'SENKSERIKOVA':
try {
const senkSerikovaFood = await getMenuSenkSerikova(firstDay, mock);
for (let i = 0; i < senkSerikovaFood.length; i++) {
weekMenu[i][restaurant]!.food = senkSerikovaFood[i];
if (senkSerikovaFood[i]?.length === 1 && senkSerikovaFood[i][0].name === 'Pro tento den nebylo zadáno menu.') {
weekMenu[i][restaurant]!.closed = true;
}
}
break;
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik Pivovarský šenk Šeříková", e);
}
}
await storage.setData(getMenuKey(usedDate), weekMenu);
}
return weekMenu[dayOfWeekIndex][restaurant]!;
}
@ -265,7 +210,7 @@ export async function initIfNeeded(date?: Date) {
* @param date datum, ke kterému se volba vztahuje
* @returns
*/
export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date) {
export async function removeChoices(login: string, trusted: boolean, locationKey: keyof typeof LunchChoice, date?: Date) {
const selectedDay = formatDate(date ?? getToday());
let data = await getClientData(date);
validateTrusted(data, login, trusted);
@ -292,14 +237,14 @@ export async function removeChoices(login: string, trusted: boolean, locationKey
* @param date datum, ke kterému se volba vztahuje
* @returns
*/
export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date) {
export async function removeChoice(login: string, trusted: boolean, locationKey: keyof typeof LunchChoice, foodIndex: number, date?: Date) {
const selectedDay = formatDate(date ?? getToday());
let data = await getClientData(date);
validateTrusted(data, login, trusted);
if (locationKey in data.choices) {
if (data.choices[locationKey] && login in data.choices[locationKey]) {
const index = data.choices[locationKey][login].selectedFoods?.indexOf(foodIndex);
if (index != null && index > -1) {
if (index && index > -1) {
data.choices[locationKey][login].selectedFoods?.splice(index, 1);
await storage.setData(selectedDay, data);
}
@ -315,11 +260,11 @@ export async function removeChoice(login: string, trusted: boolean, locationKey:
* @param date datum, ke kterému se volby vztahují
* @param ignoredLocationKey volba, která nebude odstraněna, pokud existuje
*/
async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocationKey?: LunchChoice) {
async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocationKey?: keyof typeof LunchChoice) {
const usedDate = date ?? getToday();
let data = await getClientData(usedDate);
for (const key of Object.keys(data.choices)) {
const locationKey = key as LunchChoice;
const locationKey = key as keyof typeof LunchChoice;
if (ignoredLocationKey != null && ignoredLocationKey == locationKey) {
continue;
}
@ -367,7 +312,7 @@ function validateTrusted(data: ClientData, login: string, trusted: boolean) {
* @param date datum, ke kterému se volba vztahuje
* @returns aktuální data
*/
export async function addChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex?: number, date?: Date) {
export async function addChoice(login: string, trusted: boolean, locationKey: keyof typeof LunchChoice, foodIndex?: number, date?: Date) {
const usedDate = date ?? getToday();
await initIfNeeded(usedDate);
let data = await getClientData(usedDate);
@ -381,7 +326,9 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
removeChoiceIfPresent(login, usedDate, locationKey);
}
// TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce
data.choices[locationKey] ??= {};
if (!(data.choices[locationKey])) {
data.choices[locationKey] = {}
}
if (!(login in data.choices[locationKey])) {
if (!data.choices[locationKey]) {
data.choices[locationKey] = {}
@ -406,7 +353,7 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
* @param foodIndex index jídla pro danou lokalitu
* @param date datum, pro které je validace prováděna
*/
async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, date?: Date) {
async function validateFoodIndex(locationKey: keyof typeof LunchChoice, foodIndex?: number, date?: Date) {
if (foodIndex != null) {
if (typeof foodIndex !== 'number') {
throw Error(`Neplatný index ${foodIndex} typu ${typeof foodIndex}`);
@ -418,7 +365,7 @@ async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, d
throw Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey} nepodporující indexy`);
}
const usedDate = date ?? getToday();
const menu = await getRestaurantMenu(locationKey as Restaurant, usedDate);
const menu = await getRestaurantMenu(locationKey as keyof typeof Restaurant, usedDate);
if (menu.food?.length && foodIndex > (menu.food.length - 1)) {
throw new Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey}`);
}

View File

@ -1,7 +1,8 @@
import { DailyStats, LunchChoice, WeeklyStats } from "../../types/gen/types.gen";
import { DailyStats, LunchChoice, WeeklyStats } from "../../types";
import { getStatsMock } from "./mock";
import { getClientData } from "./service";
import getStorage from "./storage";
import { formatDate } from "./utils";
const storage = getStorage();
@ -39,7 +40,7 @@ export async function getStats(startDate: string, endDate: string): Promise<Week
locationsStats.locations = {}
}
// TODO dořešit, tohle je zmatek a té hlášce Sonaru nerozumím
locationsStats.locations[locationKey as LunchChoice] = Object.keys(data.choices[locationKey as LunchChoice]!).length;
locationsStats.locations[locationKey as keyof typeof LunchChoice] = Object.keys(data.choices[locationKey as keyof typeof LunchChoice]!).length;
})
}
result.push(locationsStats);

View File

@ -5,12 +5,6 @@
* Postupem času lze předělat pro efektivnější využití Redis.
*/
export interface StorageInterface {
/**
* Inicializuje úložiště, pokud je potřeba (např. připojení k databázi).
*/
initialize?(): Promise<void>;
/**
* Vrátí příznak, zda existují data pro předaný klíč.
* @param key klíč, pro který zjišťujeme data (typicky datum)

View File

@ -4,8 +4,8 @@ import { StorageInterface } from "./StorageInterface";
import JsonStorage from "./json";
import RedisStorage from "./redis";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../../.env.${ENVIRONMENT}`) });
const ENVIRONMENT = process.env.NODE_ENV || 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
const JSON_KEY = 'json';
const REDIS_KEY = 'redis';
@ -19,13 +19,6 @@ if (!process.env.STORAGE || process.env.STORAGE?.toLowerCase() === JSON_KEY) {
throw Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json' nebo 'redis'");
}
(async () => {
if (storage.initialize) {
await storage.initialize();
}
})();
export default function getStorage(): StorageInterface {
return storage;
}

View File

@ -8,18 +8,15 @@ let client: RedisClientType;
*/
export default class RedisStorage implements StorageInterface {
constructor() {
const HOST = process.env.REDIS_HOST ?? 'localhost';
const PORT = process.env.REDIS_PORT ?? 6379;
const HOST = process.env.REDIS_HOST || 'localhost';
const PORT = process.env.REDIS_PORT || 6379;
client = createClient({ url: `redis://${HOST}:${PORT}` });
}
async initialize() {
client.connect();
}
async hasData(key: string) {
const data = await client.json.get(key);
return (!!data);
return (data ? true : false);
}
async getData<Type>(key: string) {

View File

@ -1,4 +1,4 @@
import { LunchChoice, LunchChoices } from "../../types/gen/types.gen";
import { LunchChoice, LunchChoices } from "../../types";
const DAY_OF_WEEK_FORMAT = new Intl.DateTimeFormat(undefined, { weekday: 'long' });
@ -8,7 +8,7 @@ export function formatDate(date: Date, format?: string) {
let month = String(date.getMonth() + 1).padStart(2, "0");
let year = String(date.getFullYear());
const f = format ?? 'YYYY-MM-DD';
const f = (format === undefined) ? 'YYYY-MM-DD' : format;
return f.replace('DD', day).replace('MM', month).replace('YYYY', year);
}
@ -61,10 +61,10 @@ export function getLastWorkDayOfWeek(date: Date) {
/** Vrátí pořadové číslo týdne předaného data v roce dle ISO 8601. */
export function getWeekNumber(inputDate: Date) {
const date = new Date(inputDate.getTime());
var date = new Date(inputDate.getTime());
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7);
const week1 = new Date(date.getFullYear(), 0, 4);
var week1 = new Date(date.getFullYear(), 0, 4);
return 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7);
}
@ -118,7 +118,7 @@ export const getUsersByLocation = (choices: LunchChoices, login?: string): strin
const result: string[] = [];
for (const location of Object.entries(choices)) {
const locationKey = location[0] as LunchChoice;
const locationKey = location[0] as keyof typeof LunchChoice;
const locationValue = location[1];
if (login && locationValue[login]) {
for (const username in choices[locationKey]) {

View File

@ -1,4 +1,4 @@
import { FeatureRequest } from "../../types/gen/types.gen";
import { FeatureRequest } from "../../types";
import getStorage from "./storage";
interface VotingData {
@ -29,7 +29,9 @@ export async function getUserVotes(login: string) {
*/
export async function updateFeatureVote(login: string, option: FeatureRequest, active: boolean): Promise<VotingData> {
let data = await storage.getData<VotingData>(STORAGE_KEY);
data ??= {};
if (data == null) {
data = {};
}
if (!(login in data)) {
data[login] = [];
}

56
types/RequestTypes.ts Normal file
View File

@ -0,0 +1,56 @@
import { FeatureRequest, LunchChoice, PizzaVariant } from "../types";
export type ILocationKey = {
locationKey: keyof typeof LunchChoice,
}
export type IDayIndex = {
dayIndex?: number,
}
export type AddChoiceRequest = IDayIndex & ILocationKey & {
foodIndex?: number,
}
export type RemoveChoicesRequest = IDayIndex & ILocationKey;
export type RemoveChoiceRequest = IDayIndex & ILocationKey & {
foodIndex: number,
}
export type UpdateNoteRequest = IDayIndex & {
note?: string,
}
export type ChangeDepartureTimeRequest = IDayIndex & {
time: string,
}
export type FinishDeliveryRequest = {
bankAccount?: string,
bankAccountHolder?: string,
}
export type AddPizzaRequest = {
pizzaIndex: number,
pizzaSizeIndex: number,
}
export type RemovePizzaRequest = {
pizzaOrder: PizzaVariant,
}
export type UpdatePizzaDayNoteRequest = {
note?: string,
}
export type UpdatePizzaFeeRequest = {
login: string,
text?: string,
price?: number,
}
export type UpdateFeatureVoteRequest = {
option: FeatureRequest,
active: boolean,
}

View File

@ -5,76 +5,642 @@ info:
servers:
- url: /api
paths:
# Obecné (/api)
/login:
$ref: "./paths/login.yml"
post:
summary: Přihlášení uživatele
security: [] # Nevyžaduje autentizaci
requestBody:
content:
application/json:
schema:
type: object
properties:
login:
type: string
description: Přihlašovací jméno uživatele. Vyžadováno pouze pokud není předáno pomocí hlaviček.
responses:
"200":
description: Přihlášení bylo úspěšné
content:
application/json:
schema:
$ref: "#/components/schemas/JWTToken"
/qr:
$ref: "./paths/getPizzaQr.yml"
get:
summary: Získání QR kódu pro platbu za Pizza day
security: [] # Nevyžaduje autentizaci
parameters:
- in: query
name: login
schema:
type: string
required: true
description: Přihlašovací jméno uživatele, pro kterého bude vrácen QR kód
responses:
"200":
description: Vygenerovaný QR kód pro platbu
content:
image/png:
schema:
type: string
format: binary
/data:
$ref: "./paths/getData.yml"
# Restaurace a jídla (/api/food)
/food/addChoice:
$ref: "./paths/food/addChoice.yml"
/food/removeChoice:
$ref: "./paths/food/removeChoice.yml"
/food/updateNote:
$ref: "./paths/food/updateNote.yml"
/food/removeChoices:
$ref: "./paths/food/removeChoices.yml"
/food/changeDepartureTime:
$ref: "./paths/food/changeDepartureTime.yml"
/food/jdemeObed:
$ref: "./paths/food/jdemeObed.yml"
# Pizza day (/api/pizzaDay)
/pizzaDay/create:
$ref: "./paths/pizzaDay/create.yml"
/pizzaDay/delete:
$ref: "./paths/pizzaDay/delete.yml"
/pizzaDay/lock:
$ref: "./paths/pizzaDay/lock.yml"
/pizzaDay/unlock:
$ref: "./paths/pizzaDay/unlock.yml"
/pizzaDay/finishOrder:
$ref: "./paths/pizzaDay/finishOrder.yml"
/pizzaDay/finishDelivery:
$ref: "./paths/pizzaDay/finishDelivery.yml"
/pizzaDay/add:
$ref: "./paths/pizzaDay/addPizza.yml"
/pizzaDay/remove:
$ref: "./paths/pizzaDay/removePizza.yml"
/pizzaDay/updatePizzaDayNote:
$ref: "./paths/pizzaDay/updatePizzaDayNote.yml"
/pizzaDay/updatePizzaFee:
$ref: "./paths/pizzaDay/updatePizzaFee.yml"
# Easter eggy (/api/easterEggs)
/easterEggs:
$ref: "./paths/easterEggs/easterEggs.yml"
/easterEggs/{url}:
$ref: "./paths/easterEggs/easterEgg.yml"
# Statistiky (/api/stats)
/stats:
$ref: "./paths/stats/stats.yml"
# Hlasování (/api/voting)
/voting/getVotes:
$ref: "./paths/voting/getVotes.yml"
/voting/updateVote:
$ref: "./paths/voting/updateVote.yml"
get:
summary: Načtení klientských dat pro aktuální nebo předaný den
parameters:
- in: query
name: dayIndex
description: Index dne v týdnu. Pokud není předán, je použit aktuální den.
schema:
type: integer
minimum: 0
maximum: 4
responses:
"200":
$ref: "#/components/responses/ClientDataResponse"
/addChoice:
post:
summary: Přidání či nahrazení volby uživatele pro zvolený den/podnik
requestBody:
required: true
content:
application/json:
schema:
required:
- locationKey
allOf:
- locationKey:
$ref: "#/components/schemas/LunchChoice"
- dayIndex:
$ref: "#/components/schemas/DayIndex"
- foodIndex:
$ref: "#/components/schemas/FoodIndex"
responses:
"200":
$ref: "#/components/responses/ClientDataResponse"
/removeChoices:
post:
summary: Odstranění volby uživatele pro zvolený den/podnik, včetně případných jídel
requestBody:
required: true
content:
application/json:
schema:
required:
- locationKey
allOf:
- locationKey:
$ref: "#/components/schemas/LunchChoice"
- dayIndex:
$ref: "#/components/schemas/DayIndex"
responses:
"200":
$ref: "#/components/responses/ClientDataResponse"
components:
schemas:
$ref: "./schemas/_index.yml"
# --- OBECNÉ ---
JWTToken:
type: object
description: Klientský JWT token pro autentizaci a autorizaci
required:
- login
- trusted
- iat
properties:
login:
type: string
description: Přihlašovací jméno uživatele
trusted:
type: boolean
description: Příznak, zda se jedná o uživatele ověřeného doménovým přihlášením
iat:
type: number
description: Časové razítko vydání tokenu
ClientData:
description: Klientská data pro jeden konkrétní den. Obsahuje menu všech načtených podniků a volby jednotlivých uživatelů.
type: object
additionalProperties: false
required:
- todayDayIndex
- date
- isWeekend
- choices
properties:
todayDayIndex:
description: Index dnešního dne v týdnu
$ref: "#/components/schemas/DayIndex"
date:
description: Human-readable datum dne
type: string
isWeekend:
description: Příznak, zda je tento den víkend
type: boolean
dayIndex:
description: Index dne v týdnu, ke kterému se vztahují tato data
$ref: "#/components/schemas/DayIndex"
choices:
$ref: "#/components/schemas/LunchChoices"
menus:
$ref: "#/components/schemas/RestaurantDayMenuMap"
pizzaDay:
$ref: "#/components/schemas/PizzaDay"
pizzaList:
description: Seznam dostupných pizz pro předaný den
type: array
items:
$ref: "#/components/schemas/Pizza"
pizzaListLastUpdate:
description: Datum a čas poslední aktualizace pizz
type: string
format: date-time
# --- OBĚDY ---
UserLunchChoice:
description: Konkrétní volba stravování jednoho uživatele v konkrétní den. Může se jednat jak o stravovací podnik, tak možnosti "budu objednávat", "neobědvám" apod.
additionalProperties: false
properties:
# TODO toto je tu z dost špatného důvodu, viz použití - mělo by se místo toho z loginu zjišťovat zda je uživatel trusted
trusted:
description: Příznak, zda byla tato volba provedena uživatelem ověřeným doménovým přihlášením
type: boolean
selectedFoods:
description: Pole indexů vybraných jídel v rámci dané restaurace. Index představuje pořadí jídla v menu dané restaurace.
type: array
items:
type: integer
departureTime:
description: Čas preferovaného odchodu do dané restaurace v human-readable formátu (např. 12:00)
type: string
note:
description: Volitelná, veřejně viditelná uživatelská poznámka k vybrané volbě
type: string
LocationLunchChoicesMap:
description: Objekt, kde klíčem je možnost stravování ((#/components/schemas/LunchChoice)) a hodnotou množina uživatelů s touto volbou ((#/components/schemas/LunchChoices)).
type: object
additionalProperties:
$ref: "#/components/schemas/UserLunchChoice"
LunchChoices:
description: Objekt, představující volby všech uživatelů pro konkrétní den. Klíčem je (#/components/schemas/LunchChoice).
type: object
properties:
SLADOVNICKA:
$ref: "#/components/schemas/LocationLunchChoicesMap"
TECHTOWER:
$ref: "#/components/schemas/LocationLunchChoicesMap"
ZASTAVKAUMICHALA:
$ref: "#/components/schemas/LocationLunchChoicesMap"
SENKSERIKOVA:
$ref: "#/components/schemas/LocationLunchChoicesMap"
SPSE:
$ref: "#/components/schemas/LocationLunchChoicesMap"
PIZZA:
$ref: "#/components/schemas/LocationLunchChoicesMap"
OBJEDNAVAM:
$ref: "#/components/schemas/LocationLunchChoicesMap"
NEOBEDVAM:
$ref: "#/components/schemas/LocationLunchChoicesMap"
ROZHODUJI:
$ref: "#/components/schemas/LocationLunchChoicesMap"
Restaurant:
description: Stravovací zařízení (restaurace, jídelna, hospoda, ...)
type: string
enum:
- Sladovnická
- TechTower
- Zastávka u Michala
- Šenk Šeříková
x-enum-varnames:
- SLADOVNICKA
- TECHTOWER
- ZASTAVKAUMICHALA
- SENKSERIKOVA
LunchChoice:
description: Konkrétní možnost stravování (konkrétní restaurace, pizza day, objednání, neobědvání, rozhodování se, ...)
type: string
enum:
- Sladovnická
- TechTower
- Zastávka u Michala
- Šenk Šeříková
- SPŠE
- Pizza day
- Budu objednávat
- Neobědvám
- Rozhoduji se
x-enum-varnames:
- SLADOVNICKA
- TECHTOWER
- ZASTAVKAUMICHALA
- SENKSERIKOVA
- SPSE
- PIZZA
- OBJEDNAVAM
- NEOBEDVAM
- ROZHODUJI
DayIndex:
description: Index dne v týdnu (0 = pondělí, 4 = pátek)
type: integer
minimum: 0
maximum: 4
FoodIndex:
description: Pořadový index jídla v menu konkrétní restaurace
type: integer
minimum: 0
Food:
description: Konkrétní jídlo z menu restaurace
type: object
additionalProperties: false
required:
- name
- isSoup
properties:
amount:
description: Množství standardní porce, např. 0,33l nebo 150g
type: string
name:
description: Název/popis jídla
type: string
price:
description: Cena ve formátu '135 Kč'
type: string
isSoup:
description: Příznak, zda se jedná o polévku
type: boolean
RestaurantDayMenu:
description: Menu restaurace na konkrétní den
type: object
additionalProperties: false
properties:
lastUpdate:
description: UNIX timestamp poslední aktualizace menu
type: integer
closed:
description: Příznak, zda je daný podnik v daný den zavřený
type: boolean
food:
description: Seznam jídel pro daný den
type: array
items:
$ref: "#/components/schemas/Food"
RestaurantDayMenuMap:
description: Objekt, kde klíčem je podnik ((#/components/schemas/Restaurant)) a hodnotou denní menu daného podniku ((#/components/schemas/RestaurantDayMenu))
type: object
additionalProperties: false
properties:
SLADOVNICKA:
$ref: "#/components/schemas/RestaurantDayMenu"
TECHTOWER:
$ref: "#/components/schemas/RestaurantDayMenu"
ZASTAVKAUMICHALA:
$ref: "#/components/schemas/RestaurantDayMenu"
SENKSERIKOVA:
$ref: "#/components/schemas/RestaurantDayMenu"
WeekMenu:
description: Pole týdenních menu jednotlivých podniků. Indexem je den v týdnu (0 = pondělí, 4 = pátek), hodnotou denní menu daného podniku.
type: array
minItems: 5
maxItems: 5
items:
$ref: "#/components/schemas/RestaurantDayMenuMap"
DepartureTime:
description: Preferovaný čas odchodu na oběd
type: string
enum:
- "10:00"
- "10:15"
- "10:30"
- "10:45"
- "11:00"
- "11:15"
- "11:30"
- "11:45"
- "12:00"
- "12:15"
- "12:30"
- "12:45"
- "13:00"
x-enum-varnames:
- T10_00
- T10_15
- T10_30
- T10_45
- T11_00
- T11_15
- T11_30
- T11_45
- T12_00
- T12_15
- T12_30
- T12_45
- T13_00
# --- HLASOVÁNÍ ---
FeatureRequest:
type: string
enum:
- Ruční generování QR kódů mimo Pizza day (např. při objednávání)
- Možnost označovat si jídla jako oblíbená (taková jídla by se uživateli následně zvýrazňovala)
- Možnost úhrady v podniku za všechny jednou osobou a následné generování QR ostatním
- Zrušení \"užívejte víkend\", místo toho umožnit zpětně náhled na uplynulý týden
- Umožnění zobrazení vygenerovaného QR kódu i po následující dny (dokud ho uživatel ručně \"nezavře\", např. tlačítkem \"Zaplatil jsem\")
- Zobrazování náhledů (fotografií) pizz v rámci Pizza day
- Statistiky (nejoblíbenější podnik, nejpopulárnější jídla, nejobjednávanější pizzy, nejčastější uživatelé, ...)
- Vylepšení responzivního designu
- Zvýšení zabezpečení aplikace
- Zvýšená ochrana proti chybám uživatele (potvrzovací dialogy, překliky, ...)
- Celkové vylepšení UI/UX
- Zlepšení dokumentace/postupů pro ostatní vývojáře
x-enum-varnames:
- CUSTOM_QR
- FAVORITES
- SINGLE_PAYMENT
- NO_WEEKENDS
- QR_FOREVER
- PIZZA_PICTURES
- STATISTICS
- RESPONSIVITY
- SECURITY
- SAFETY
- UI
- DEVELOPMENT
# --- EASTER EGGS ---
EasterEgg:
description: Data pro zobrazení easter eggů
type: object
additionalProperties: false
required:
- path
- url
- startOffset
- endOffset
- duration
properties:
path:
type: string
url:
type: string
startOffset:
type: number
endOffset:
type: number
duration:
type: number
width:
type: string
zIndex:
type: integer
position:
type: string
enum:
- absolute
animationName:
type: string
animationDuration:
type: string
animationTimingFunction:
type: string
# --- STATISTIKY ---
LocationStats:
description: Objekt, kde klíčem je zvolená možnost a hodnotou počet uživatelů, kteří tuto možnosti zvolili
type: object
additionalProperties: false
properties:
# Bohužel OpenAPI neumí nadefinovat objekt, kde klíčem může být pouze hodnota existujícího enumu :(
SLADOVNICKA:
type: number
TECHTOWER:
type: number
ZASTAVKAUMICHALA:
type: number
SENKSERIKOVA:
type: number
SPSE:
type: number
PIZZA:
type: number
OBJEDNAVAM:
type: number
NEOBEDVAM:
type: number
ROZHODUJI:
type: number
DailyStats:
description: Statistika vybraných možností pro jeden konkrétní den
type: object
additionalProperties: false
required:
- date
- locations
properties:
date:
description: Datum v human-readable formátu
type: string
locations:
$ref: "#/components/schemas/LocationStats"
WeeklyStats:
description: Pole statistik vybraných možností pro jeden konkrétní týden. Index představuje den v týdnu (0 = pondělí, 4 = pátek)
type: array
minItems: 5
maxItems: 5
items:
$ref: "#/components/schemas/DailyStats"
# --- PIZZA DAY ---
PizzaDayState:
description: Stav pizza day
type: string
enum:
- Pizza day nebyl založen
- Pizza day je založen
- Objednávky uzamčeny
- Pizzy objednány
- Pizzy doručeny
x-enum-varnames:
- NOT_CREATED
- CREATED
- LOCKED
- ORDERED
- DELIVERED
# TODO toto je jen rozšířená varianta PizzaVariant - sloučit do jednoho objektu
PizzaSize:
description: Údaje o konkrétní variantě pizzy
type: object
additionalProperties: false
required:
- varId
- size
- pizzaPrice
- boxPrice
- price
properties:
varId:
description: Unikátní identifikátor varianty pizzy
type: integer
size:
description: Velikost pizzy, např. "30cm"
type: string
pizzaPrice:
description: Cena samotné pizzy v Kč
type: number
boxPrice:
description: Cena krabice pizzy v Kč
type: number
price:
description: Celková cena (pizza + krabice)
type: number
Pizza:
description: Údaje o konkrétní pizze.
type: object
additionalProperties: false
required:
- name
- ingredients
- sizes
properties:
name:
description: Název pizzy
type: string
ingredients:
description: Seznam obsažených ingrediencí
type: array
items:
type: string
sizes:
description: Dostupné velikosti pizzy
type: array
items:
$ref: "#/components/schemas/PizzaSize"
PizzaVariant:
description: Konkrétní varianta (velikost) jedné pizzy.
type: object
additionalProperties: false
required:
- varId
- name
- size
- price
properties:
varId:
description: Unikátní identifikátor varianty pizzy
type: integer
name:
description: Název pizzy
type: string
size:
description: Velikost pizzy (např. "30cm")
type: string
price:
description: Cena pizzy v Kč, včetně krabice
type: number
PizzaOrder:
description: Údaje o objednávce pizzy jednoho uživatele.
type: object
additionalProperties: false
required:
- customer
- totalPrice
- hasQr
properties:
customer:
description: Jméno objednávajícího uživatele
type: string
pizzaList:
description: Seznam variant pizz k objednání (typicky bývá jen jedna)
type: array
items:
$ref: "#/components/schemas/PizzaVariant"
fee:
description: Příplatek (např. za extra ingredience)
type: object
properties:
text:
description: Popis příplatku (např. "kuřecí maso navíc")
type: string
price:
description: Cena příplatku v Kč
type: number
totalPrice:
description: Celková cena všech objednaných pizz daného uživatele, včetně krabic a příplatků
type: number
hasQr:
description: |
Příznak, pokud je k této objednávce vygenerován QR kód pro platbu. To je typicky pravda, pokud:
- objednávající má v nastavení vyplněno číslo účtu
- pizza day je ve stavu DELIVERED (Pizzy byly doručeny)
note:
description: Volitelná uživatelská poznámka pro objednávajícího (např. "bez oliv")
type: string
PizzaDay:
description: Data o Pizza day pro konkrétní den
type: object
additionalProperties: false
properties:
state:
$ref: "#/components/schemas/PizzaDayState"
creator:
description: "Jméno zakladatele pizza day"
type: string
orders:
description: Pole objednávek jednotlivých uživatelů
type: array
items:
$ref: "#/components/schemas/PizzaOrder"
# --- NOTIFIKACE ---
UdalostEnum:
type: string
enum:
- Zahájen pizza day
- Objednána pizza
- Jdeme na oběd
x-enum-varnames:
- ZAHAJENA_PIZZA
- OBJEDNANA_PIZZA
- JDEME_NA_OBED
NotifikaceInput:
type: object
required:
- udalost
- user
properties:
udalost:
$ref: "#/components/schemas/UdalostEnum"
user:
type: string
NotifikaceData:
type: object
required:
- input
properties:
input:
$ref: "#/components/schemas/NotifikaceInput"
gotify:
type: boolean
teams:
type: boolean
ntfy:
type: boolean
GotifyServer:
type: object
required:
- server
- api_keys
properties:
server:
type: string
api_keys:
type: array
items:
type: string
responses:
ClientDataResponse:
description: Aktuální data pro klienta
content:
application/json:
schema:
$ref: "./schemas/_index.yml#/ClientData"
$ref: "#/components/schemas/ClientData"
securitySchemes:
bearerAuth:
type: http

View File

@ -1 +1,2 @@
export * from './RequestTypes';
export * from './gen';

View File

@ -1,18 +0,0 @@
get:
operationId: getEasterEggImage
summary: Vrátí obrázek konkrétního easter eggu
parameters:
- in: path
name: url
required: true
schema:
type: string
description: URL easter eggu
responses:
"200":
content:
image/png:
description: Obrázek easter eggu
schema:
type: string
format: binary

View File

@ -1,9 +0,0 @@
get:
operationId: getEasterEgg
summary: Vrátí náhodně metadata jednoho z definovaných easter egg obrázků pro přihlášeného uživatele, nebo nic, pokud žádné definované nemá.
responses:
"200":
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/EasterEgg"

View File

@ -1,20 +0,0 @@
post:
operationId: addChoice
summary: Přidání či nahrazení volby uživatele pro zvolený den/podnik
requestBody:
required: true
content:
application/json:
schema:
required:
- locationKey
properties:
locationKey:
$ref: "../../schemas/_index.yml#/LunchChoice"
dayIndex:
$ref: "../../schemas/_index.yml#/DayIndex"
foodIndex:
$ref: "../../schemas/_index.yml#/FoodIndex"
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"

View File

@ -1,16 +0,0 @@
post:
operationId: changeDepartureTime
summary: Úprava preferovaného času odchodu do aktuálně zvoleného podniku.
requestBody:
required: true
content:
application/json:
schema:
properties:
dayIndex:
$ref: "../../schemas/_index.yml#/DayIndex"
time:
$ref: "../../schemas/_index.yml#/DepartureTime"
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"

View File

@ -1,6 +0,0 @@
post:
operationId: jdemeObed
summary: Odeslání notifikací "jdeme na oběd" dle konfigurace.
responses:
"200":
description: Notifikace byly odeslány.

View File

@ -1,21 +0,0 @@
post:
operationId: removeChoice
summary: Odstranění jednoho zvoleného jídla uživatele pro zvolený den/podnik
requestBody:
required: true
content:
application/json:
schema:
required:
- foodIndex
- locationKey
properties:
foodIndex:
$ref: "../../schemas/_index.yml#/FoodIndex"
locationKey:
$ref: "../../schemas/_index.yml#/LunchChoice"
dayIndex:
$ref: "../../schemas/_index.yml#/DayIndex"
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"

View File

@ -1,18 +0,0 @@
post:
operationId: removeChoices
summary: Odstranění volby uživatele pro zvolený den/podnik, včetně případných jídel
requestBody:
required: true
content:
application/json:
schema:
required:
- locationKey
properties:
locationKey:
$ref: "../../schemas/_index.yml#/LunchChoice"
dayIndex:
$ref: "../../schemas/_index.yml#/DayIndex"
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"

View File

@ -1,16 +0,0 @@
post:
operationId: updateNote
summary: Nastavení poznámky k volbě uživatele
requestBody:
required: true
content:
application/json:
schema:
properties:
dayIndex:
$ref: "../../schemas/_index.yml#/DayIndex"
note:
type: string
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"

View File

@ -1,14 +0,0 @@
get:
operationId: getData
summary: Načtení klientských dat pro aktuální nebo předaný den
parameters:
- in: query
name: dayIndex
description: Index dne v týdnu. Pokud není předán, je použit aktuální den.
schema:
type: integer
minimum: 0
maximum: 4
responses:
"200":
$ref: "../api.yml#/components/responses/ClientDataResponse"

View File

@ -1,19 +0,0 @@
get:
operationId: getPizzaQr
summary: Získání QR kódu pro platbu za Pizza day
security: [] # Nevyžaduje autentizaci
parameters:
- in: query
name: login
schema:
type: string
required: true
description: Přihlašovací jméno uživatele, pro kterého bude vrácen QR kód
responses:
"200":
description: Vygenerovaný QR kód pro platbu
content:
image/png:
schema:
type: string
format: binary

View File

@ -1,20 +0,0 @@
post:
operationId: login
summary: Přihlášení uživatele
security: [] # Nevyžaduje autentizaci
requestBody:
content:
application/json:
schema:
type: object
properties:
login:
type: string
description: Přihlašovací jméno uživatele. Vyžadováno pouze pokud není předáno pomocí hlaviček.
responses:
"200":
description: Přihlášení bylo úspěšné
content:
application/json:
schema:
$ref: "../schemas/_index.yml#/JWTToken"

View File

@ -1,21 +0,0 @@
post:
operationId: addPizza
summary: Přidání pizzy do objednávky.
requestBody:
required: true
content:
application/json:
schema:
required:
- pizzaIndex
- pizzaSizeIndex
properties:
pizzaIndex:
description: Index pizzy v nabídce
type: integer
pizzaSizeIndex:
description: Index velikosti pizzy v nabídce variant
type: integer
responses:
"200":
description: Přidání pizzy do objednávky proběhlo úspěšně.

View File

@ -1,6 +0,0 @@
post:
operationId: createPizzaDay
summary: Založení pizza day.
responses:
"200":
description: Pizza day byl založen.

View File

@ -1,6 +0,0 @@
post:
operationId: deletePizzaDay
summary: Smazání pizza day.
responses:
"200":
description: Pizza day byl smazán.

View File

@ -1,18 +0,0 @@
post:
operationId: finishDelivery
summary: Převod pizza day do stavu "Pizzy byly doručeny". Pokud má objednávající nastaveno číslo účtu, je ostatním uživatelům vygenerován a následně zobrazen QR kód pro úhradu jejich objednávky.
requestBody:
required: true
content:
application/json:
schema:
properties:
bankAccount:
description: Číslo bankovního účtu objednávajícího
type: string
bankAccountHolder:
description: Jméno majitele bankovního účtu
type: string
responses:
"200":
description: Pizza day byl přepnut do stavu "Pizzy doručeny".

View File

@ -1,6 +0,0 @@
post:
operationId: finishOrder
summary: Přepnutí pizza day do stavu "Pizzy objednány". Není možné měnit objednávky, příslušným uživatelům je odeslána notifikace o provedené objednávce.
responses:
"200":
description: Pizza day byl přepnut do stavu "Pizzy objednány".

View File

@ -1,6 +0,0 @@
post:
operationId: lockPizzaDay
summary: Uzamkne pizza day. Nebude možné přidávat či odebírat pizzy.
responses:
"200":
description: Pizza day byl uzamčen.

View File

@ -1,16 +0,0 @@
post:
operationId: removePizza
summary: Odstranění pizzy z objednávky.
requestBody:
required: true
content:
application/json:
schema:
required:
- pizzaOrder
properties:
pizzaOrder:
$ref: "../../schemas/_index.yml#/PizzaVariant"
responses:
"200":
description: Odstranění pizzy z objednávky proběhlo úspěšně.

View File

@ -1,6 +0,0 @@
post:
operationId: unlockPizzaDay
summary: Odemkne pizza day. Bude opět možné přidávat či odebírat pizzy.
responses:
"200":
description: Pizza day byl odemčen.

View File

@ -1,15 +0,0 @@
post:
operationId: updatePizzaDayNote
summary: Nastavení poznámky k objednávkám pizz přihlášeného uživatele.
requestBody:
required: true
content:
application/json:
schema:
properties:
note:
type: string
description: Poznámka k objednávkám pizz, např "bez oliv".
responses:
"200":
description: Nastavení poznámky k objednávkám pizz proběhlo úspěšně.

View File

@ -1,23 +0,0 @@
post:
operationId: updatePizzaFee
summary: Nastavení přirážky/slevy k objednávce pizz uživatele.
requestBody:
required: true
content:
application/json:
schema:
required:
- login
properties:
login:
type: string
description: Login cíleného uživatele
text:
type: string
description: Textový popis přirážky/slevy
price:
type: number
description: Částka přirážky/slevy v Kč
responses:
"200":
description: Nastavení přirážky/slevy proběhlo úspěšně.

View File

@ -1,23 +0,0 @@
get:
operationId: getStats
summary: Vrátí statistiky způsobu stravování pro předaný rozsah dat.
parameters:
- in: query
name: startDate
required: true
schema:
type: string
description: Počáteční datum pro načtení statistik
- in: query
name: endDate
required: true
schema:
type: string
description: Koncové datum pro načtení statistik
responses:
"200":
description: Statistiky způsobu stravování. Každý prvek v poli představuje statistiky pro jeden den z předaného rozsahu dat.
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/WeeklyStats"

View File

@ -1,11 +0,0 @@
get:
operationId: getVotes
summary: Vrátí statistiky hlasování o nových funkcích.
responses:
"200":
content:
application/json:
schema:
type: array
items:
$ref: "../../schemas/_index.yml#/FeatureRequest"

View File

@ -1,22 +0,0 @@
post:
operationId: updateVote
summary: Aktualizuje hlasování uživatele o dané funkcionalitě.
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- option
- active
properties:
option:
description: Hlasovací možnost, kterou uživatel zvolil.
$ref: "../../schemas/_index.yml#/FeatureRequest"
active:
type: boolean
description: True, pokud uživatel hlasoval pro, jinak false.
responses:
"200":
description: Hlasování bylo úspěšně aktualizováno.

View File

@ -1,521 +0,0 @@
# --- OBECNÉ ---
JWTToken:
type: object
description: Klientský JWT token pro autentizaci a autorizaci
required:
- login
- trusted
- iat
properties:
login:
type: string
description: Přihlašovací jméno uživatele
trusted:
type: boolean
description: Příznak, zda se jedná o uživatele ověřeného doménovým přihlášením
iat:
type: number
description: Časové razítko vydání tokenu
ClientData:
description: Klientská data pro jeden konkrétní den. Obsahuje menu všech načtených podniků a volby jednotlivých uživatelů.
type: object
additionalProperties: false
required:
- todayDayIndex
- date
- isWeekend
- choices
properties:
todayDayIndex:
description: Index dnešního dne v týdnu
$ref: "#/DayIndex"
date:
description: Human-readable datum dne
type: string
isWeekend:
description: Příznak, zda je tento den víkend
type: boolean
dayIndex:
description: Index dne v týdnu, ke kterému se vztahují tato data
$ref: "#/DayIndex"
choices:
$ref: "#/LunchChoices"
menus:
$ref: "#/RestaurantDayMenuMap"
pizzaDay:
$ref: "#/PizzaDay"
pizzaList:
description: Seznam dostupných pizz pro předaný den
type: array
items:
$ref: "#/Pizza"
pizzaListLastUpdate:
description: Datum a čas poslední aktualizace pizz
type: string
format: date-time
# --- OBĚDY ---
UserLunchChoice:
description: Konkrétní volba stravování jednoho uživatele v konkrétní den. Může se jednat jak o stravovací podnik, tak možnosti "budu objednávat", "neobědvám" apod.
additionalProperties: false
properties:
# TODO toto je tu z dost špatného důvodu, viz použití - mělo by se místo toho z loginu zjišťovat zda je uživatel trusted
trusted:
description: Příznak, zda byla tato volba provedena uživatelem ověřeným doménovým přihlášením
type: boolean
selectedFoods:
description: Pole indexů vybraných jídel v rámci dané restaurace. Index představuje pořadí jídla v menu dané restaurace.
type: array
items:
type: integer
departureTime:
description: Čas preferovaného odchodu do dané restaurace v human-readable formátu (např. 12:00)
type: string
note:
description: Volitelná, veřejně viditelná uživatelská poznámka k vybrané volbě
type: string
LocationLunchChoicesMap:
description: Objekt, kde klíčem je možnost stravování ((#LunchChoice)) a hodnotou množina uživatelů s touto volbou ((#LunchChoices)).
type: object
additionalProperties:
$ref: "#/UserLunchChoice"
LunchChoices:
description: Objekt, představující volby všech uživatelů pro konkrétní den. Klíčem je (#LunchChoice).
type: object
properties:
SLADOVNICKA:
$ref: "#/LocationLunchChoicesMap"
TECHTOWER:
$ref: "#/LocationLunchChoicesMap"
ZASTAVKAUMICHALA:
$ref: "#/LocationLunchChoicesMap"
SENKSERIKOVA:
$ref: "#/LocationLunchChoicesMap"
SPSE:
$ref: "#/LocationLunchChoicesMap"
PIZZA:
$ref: "#/LocationLunchChoicesMap"
OBJEDNAVAM:
$ref: "#/LocationLunchChoicesMap"
NEOBEDVAM:
$ref: "#/LocationLunchChoicesMap"
ROZHODUJI:
$ref: "#/LocationLunchChoicesMap"
Restaurant:
description: Stravovací zařízení (restaurace, jídelna, hospoda, ...)
type: string
enum:
- SLADOVNICKA
- TECHTOWER
- ZASTAVKAUMICHALA
- SENKSERIKOVA
LunchChoice:
description: Konkrétní možnost stravování (konkrétní restaurace, pizza day, objednání, neobědvání, rozhodování se, ...)
type: string
enum:
- SLADOVNICKA
- TECHTOWER
- ZASTAVKAUMICHALA
- SENKSERIKOVA
- SPSE
- PIZZA
- OBJEDNAVAM
- NEOBEDVAM
- ROZHODUJI
DayIndex:
description: Index dne v týdnu (0 = pondělí, 4 = pátek)
type: integer
minimum: 0
maximum: 4
FoodIndex:
description: Pořadový index jídla v menu konkrétní restaurace
type: integer
minimum: 0
Food:
description: Konkrétní jídlo z menu restaurace
type: object
additionalProperties: false
required:
- name
- isSoup
properties:
amount:
description: Množství standardní porce, např. 0,33l nebo 150g
type: string
name:
description: Název/popis jídla
type: string
price:
description: Cena ve formátu '135 Kč'
type: string
isSoup:
description: Příznak, zda se jedná o polévku
type: boolean
RestaurantDayMenu:
description: Menu restaurace na konkrétní den
type: object
additionalProperties: false
properties:
lastUpdate:
description: UNIX timestamp poslední aktualizace menu
type: integer
closed:
description: Příznak, zda je daný podnik v daný den zavřený
type: boolean
food:
description: Seznam jídel pro daný den
type: array
items:
$ref: "#/Food"
RestaurantDayMenuMap:
description: Objekt, kde klíčem je podnik ((#Restaurant)) a hodnotou denní menu daného podniku ((#RestaurantDayMenu))
type: object
additionalProperties: false
properties:
SLADOVNICKA:
$ref: "#/RestaurantDayMenu"
TECHTOWER:
$ref: "#/RestaurantDayMenu"
ZASTAVKAUMICHALA:
$ref: "#/RestaurantDayMenu"
SENKSERIKOVA:
$ref: "#/RestaurantDayMenu"
WeekMenu:
description: Pole týdenních menu jednotlivých podniků. Indexem je den v týdnu (0 = pondělí, 4 = pátek), hodnotou denní menu daného podniku.
type: array
minItems: 5
maxItems: 5
items:
$ref: "#/RestaurantDayMenuMap"
DepartureTime:
description: Preferovaný čas odchodu na oběd
type: string
enum:
- "10:00"
- "10:15"
- "10:30"
- "10:45"
- "11:00"
- "11:15"
- "11:30"
- "11:45"
- "12:00"
- "12:15"
- "12:30"
- "12:45"
- "13:00"
x-enum-varnames:
- T10_00
- T10_15
- T10_30
- T10_45
- T11_00
- T11_15
- T11_30
- T11_45
- T12_00
- T12_15
- T12_30
- T12_45
- T13_00
# --- HLASOVÁNÍ ---
FeatureRequest:
type: string
enum:
- Ruční generování QR kódů mimo Pizza day (např. při objednávání)
- Možnost označovat si jídla jako oblíbená (taková jídla by se uživateli následně zvýrazňovala)
- Možnost úhrady v podniku za všechny jednou osobou a následné generování QR ostatním
- Zrušení \"užívejte víkend\", místo toho umožnit zpětně náhled na uplynulý týden
- Umožnění zobrazení vygenerovaného QR kódu i po následující dny (dokud ho uživatel ručně \"nezavře\", např. tlačítkem \"Zaplatil jsem\")
- Zobrazování náhledů (fotografií) pizz v rámci Pizza day
- Statistiky (nejoblíbenější podnik, nejpopulárnější jídla, nejobjednávanější pizzy, nejčastější uživatelé, ...)
- Vylepšení responzivního designu
- Zvýšení zabezpečení aplikace
- Zvýšená ochrana proti chybám uživatele (potvrzovací dialogy, překliky, ...)
- Celkové vylepšení UI/UX
- Zlepšení dokumentace/postupů pro ostatní vývojáře
x-enum-varnames:
- CUSTOM_QR
- FAVORITES
- SINGLE_PAYMENT
- NO_WEEKENDS
- QR_FOREVER
- PIZZA_PICTURES
- STATISTICS
- RESPONSIVITY
- SECURITY
- SAFETY
- UI
- DEVELOPMENT
# --- EASTER EGGS ---
EasterEgg:
description: Data pro zobrazení easter eggů ssss
type: object
additionalProperties: false
required:
- path
- url
- startOffset
- endOffset
- duration
properties:
path:
type: string
url:
type: string
startOffset:
type: number
endOffset:
type: number
duration:
type: number
width:
type: string
zIndex:
type: integer
position:
type: string
enum:
- absolute
animationName:
type: string
animationDuration:
type: string
animationTimingFunction:
type: string
# --- STATISTIKY ---
LocationStats:
description: Objekt, kde klíčem je zvolená možnost a hodnotou počet uživatelů, kteří tuto možnosti zvolili
type: object
additionalProperties: false
properties:
# Bohužel OpenAPI neumí nadefinovat objekt, kde klíčem může být pouze hodnota existujícího enumu :(
SLADOVNICKA:
type: number
TECHTOWER:
type: number
ZASTAVKAUMICHALA:
type: number
SENKSERIKOVA:
type: number
SPSE:
type: number
PIZZA:
type: number
OBJEDNAVAM:
type: number
NEOBEDVAM:
type: number
ROZHODUJI:
type: number
DailyStats:
description: Statistika vybraných možností pro jeden konkrétní den
type: object
additionalProperties: false
required:
- date
- locations
properties:
date:
description: Datum v human-readable formátu
type: string
locations:
$ref: "#/LocationStats"
WeeklyStats:
description: Pole statistik vybraných možností pro jeden konkrétní týden. Index představuje den v týdnu (0 = pondělí, 4 = pátek)
type: array
minItems: 5
maxItems: 5
items:
$ref: "#/DailyStats"
# --- PIZZA DAY ---
PizzaDayState:
description: Stav pizza day
type: string
enum:
- Pizza day nebyl založen
- Pizza day je založen
- Objednávky uzamčeny
- Pizzy objednány
- Pizzy doručeny
x-enum-varnames:
- NOT_CREATED
- CREATED
- LOCKED
- ORDERED
- DELIVERED
# TODO toto je jen rozšířená varianta PizzaVariant - sloučit do jednoho objektu
PizzaSize:
description: Údaje o konkrétní variantě pizzy
type: object
additionalProperties: false
required:
- varId
- size
- pizzaPrice
- boxPrice
- price
properties:
varId:
description: Unikátní identifikátor varianty pizzy
type: integer
size:
description: Velikost pizzy, např. "30cm"
type: string
pizzaPrice:
description: Cena samotné pizzy v Kč
type: number
boxPrice:
description: Cena krabice pizzy v Kč
type: number
price:
description: Celková cena (pizza + krabice)
type: number
Pizza:
description: Údaje o konkrétní pizze.
type: object
additionalProperties: false
required:
- name
- ingredients
- sizes
properties:
name:
description: Název pizzy
type: string
ingredients:
description: Seznam obsažených ingrediencí
type: array
items:
type: string
sizes:
description: Dostupné velikosti pizzy
type: array
items:
$ref: "#/PizzaSize"
PizzaVariant:
description: Konkrétní varianta (velikost) jedné pizzy.
type: object
additionalProperties: false
required:
- varId
- name
- size
- price
properties:
varId:
description: Unikátní identifikátor varianty pizzy
type: integer
name:
description: Název pizzy
type: string
size:
description: Velikost pizzy (např. "30cm")
type: string
price:
description: Cena pizzy v Kč, včetně krabice
type: number
PizzaOrder:
description: Údaje o objednávce pizzy jednoho uživatele.
type: object
additionalProperties: false
required:
- customer
- totalPrice
- hasQr
properties:
customer:
description: Jméno objednávajícího uživatele
type: string
pizzaList:
description: Seznam variant pizz k objednání (typicky bývá jen jedna)
type: array
items:
$ref: "#/PizzaVariant"
fee:
description: Příplatek (např. za extra ingredience)
type: object
properties:
text:
description: Popis příplatku (např. "kuřecí maso navíc")
type: string
price:
description: Cena příplatku v Kč
type: number
totalPrice:
description: Celková cena všech objednaných pizz daného uživatele, včetně krabic a příplatků
type: number
hasQr:
description: |
Příznak, pokud je k této objednávce vygenerován QR kód pro platbu. To je typicky pravda, pokud:
- objednávající má v nastavení vyplněno číslo účtu
- pizza day je ve stavu DELIVERED (Pizzy byly doručeny)
note:
description: Volitelná uživatelská poznámka pro objednávajícího (např. "bez oliv")
type: string
PizzaDay:
description: Data o Pizza day pro konkrétní den
type: object
additionalProperties: false
properties:
state:
$ref: "#/PizzaDayState"
creator:
description: "Jméno zakladatele pizza day"
type: string
orders:
description: Pole objednávek jednotlivých uživatelů
type: array
items:
$ref: "#/PizzaOrder"
# --- NOTIFIKACE ---
UdalostEnum:
type: string
enum:
- Zahájen pizza day
- Objednána pizza
- Jdeme na oběd
x-enum-varnames:
- ZAHAJENA_PIZZA
- OBJEDNANA_PIZZA
- JDEME_NA_OBED
NotifikaceInput:
type: object
required:
- udalost
- user
properties:
udalost:
$ref: "#/UdalostEnum"
user:
type: string
NotifikaceData:
type: object
required:
- input
properties:
input:
$ref: "#/NotifikaceInput"
gotify:
type: boolean
teams:
type: boolean
ntfy:
type: boolean
GotifyServer:
type: object
required:
- server
- api_keys
properties:
server:
type: string
api_keys:
type: array
items:
type: string