feat: večeře (extra meal slot)
CI / Generate TypeScript types (pull_request) Successful in 11s
CI / Generate TypeScript types (push) Successful in 36s
CI / Server unit tests (pull_request) Successful in 25s
CI / Build client (pull_request) Successful in 37s
CI / Server unit tests (push) Successful in 22s
CI / Build server (push) Successful in 1m0s
CI / Build client (push) Successful in 37s
CI / Build server (pull_request) Successful in 3m14s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Build and push Docker image (push) Has been skipped
CI / Notify (push) Successful in 2s
CI / Playwright E2E tests (pull_request) Successful in 10m34s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Notify (pull_request) Has been skipped
CI / Generate TypeScript types (pull_request) Successful in 11s
CI / Generate TypeScript types (push) Successful in 36s
CI / Server unit tests (pull_request) Successful in 25s
CI / Build client (pull_request) Successful in 37s
CI / Server unit tests (push) Successful in 22s
CI / Build server (push) Successful in 1m0s
CI / Build client (push) Successful in 37s
CI / Build server (pull_request) Successful in 3m14s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Build and push Docker image (push) Has been skipped
CI / Notify (push) Successful in 2s
CI / Playwright E2E tests (pull_request) Successful in 10m34s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Notify (pull_request) Has been skipped
- Nová stránka /vecere pro evidenci extra jídla (večeře/pozdní oběd) - MealSlot enum (obed/extra), oddělený storage namespace YYYY-MM-DD_extra - slot parametr na všech food endpointech a GET /api/data - Push reminder: přechod na 60min cooldown, login v payloadu místo endpointu - server: slot?: string → slot?: MealSlot, enum konstanty místo literálů - Jest testy izolace extra/obed storage namespace - Aktualizace changelogů (saláty, SINGLE_PAYMENT, večeře)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
[
|
||||
"Zobrazení nabídky salátů z Pizza Chefie"
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
[
|
||||
"Možnost úhrady celého účtu jednou osobou s rozesláním QR kódů ostatním (na Pizza day)"
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
[
|
||||
"Evidence večeří a pozdních obědů na samostatné stránce (/vecere)"
|
||||
]
|
||||
+6
-1
@@ -2,6 +2,7 @@ import express from "express";
|
||||
import bodyParser from "body-parser";
|
||||
import cors from 'cors';
|
||||
import { getData, getDateForWeekIndex, getToday } from "./service";
|
||||
import { MealSlot } from "../../types/gen/types.gen";
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { getQr } from "./qr";
|
||||
@@ -151,7 +152,11 @@ app.get("/api/data", async (req, res) => {
|
||||
// Na víkendu zobrazíme pátek místo hlášky "Užívejte víkend"
|
||||
date = getDateForWeekIndex(4);
|
||||
}
|
||||
const data = await getData(date);
|
||||
const slotParam = typeof req.query.slot === 'string' ? req.query.slot as MealSlot : undefined;
|
||||
if (slotParam && slotParam !== MealSlot.OBED && slotParam !== MealSlot.EXTRA) {
|
||||
return res.status(400).json({ error: 'Neplatný slot' });
|
||||
}
|
||||
const data = await getData(date, slotParam);
|
||||
// Připojíme nevyřízené QR kódy pro přihlášeného uživatele
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
|
||||
@@ -14,13 +14,10 @@ interface RegistryEntry {
|
||||
|
||||
type Registry = Record<string, RegistryEntry>;
|
||||
|
||||
/** Mapa login → datum (YYYY-MM-DD), kdy byl uživatel naposledy upozorněn. */
|
||||
const remindedToday = new Map<string, string>();
|
||||
/** Mapa login → timestamp (ms) posledního odeslání připomínky. */
|
||||
const lastReminded = new Map<string, number>();
|
||||
|
||||
function getTodayDateString(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
const REMINDER_COOLDOWN_MS = 60 * 60 * 1000; // 60 minut mezi připomínkami
|
||||
|
||||
function getCurrentTimeHHMM(): string {
|
||||
const now = new Date();
|
||||
@@ -59,7 +56,7 @@ export async function unsubscribePush(login: string): Promise<void> {
|
||||
const registry = await getRegistry();
|
||||
delete registry[login];
|
||||
await saveRegistry(registry);
|
||||
remindedToday.delete(login);
|
||||
lastReminded.delete(login);
|
||||
console.log(`Push reminder: uživatel ${login} odhlášen z připomínek`);
|
||||
}
|
||||
|
||||
@@ -68,16 +65,6 @@ export function getVapidPublicKey(): string | undefined {
|
||||
return process.env.VAPID_PUBLIC_KEY;
|
||||
}
|
||||
|
||||
/** Najde login uživatele podle push subscription endpointu. */
|
||||
export async function findLoginByEndpoint(endpoint: string): Promise<string | undefined> {
|
||||
const registry = await getRegistry();
|
||||
for (const [login, entry] of Object.entries(registry)) {
|
||||
if (entry.subscription.endpoint === endpoint) {
|
||||
return login;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */
|
||||
async function checkAndSendReminders(): Promise<void> {
|
||||
@@ -93,7 +80,6 @@ async function checkAndSendReminders(): Promise<void> {
|
||||
}
|
||||
|
||||
const currentTime = getCurrentTimeHHMM();
|
||||
const todayStr = getTodayDateString();
|
||||
|
||||
// Získáme data pro dnešek jednou pro všechny uživatele
|
||||
let clientData;
|
||||
@@ -110,8 +96,9 @@ async function checkAndSendReminders(): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Už jsme dnes připomenuli
|
||||
if (remindedToday.get(login) === todayStr) {
|
||||
// Cooldown — nepřipomínat častěji než jednou za hodinu
|
||||
const last = lastReminded.get(login) ?? 0;
|
||||
if (Date.now() - last < REMINDER_COOLDOWN_MS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -127,9 +114,10 @@ async function checkAndSendReminders(): Promise<void> {
|
||||
JSON.stringify({
|
||||
title: 'Luncher',
|
||||
body: 'Ještě nemáte zvolený oběd!',
|
||||
login,
|
||||
})
|
||||
);
|
||||
remindedToday.set(login, todayStr);
|
||||
lastReminded.set(login, Date.now());
|
||||
console.log(`Push reminder: odeslána připomínka uživateli ${login}`);
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 410 || error.statusCode === 404) {
|
||||
|
||||
@@ -189,7 +189,7 @@ router.post("/testPush", async (req, res, next) => {
|
||||
webpush.setVapidDetails(subject, publicKey, privateKey);
|
||||
await webpush.sendNotification(
|
||||
entry.subscription,
|
||||
JSON.stringify({ title: 'Luncher test', body: 'Push notifikace fungují!' })
|
||||
JSON.stringify({ title: 'Luncher test', body: 'Push notifikace fungují!', login })
|
||||
);
|
||||
res.status(200).json({ ok: true });
|
||||
} catch (e: any) { next(e) }
|
||||
|
||||
@@ -4,7 +4,7 @@ import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices,
|
||||
import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils";
|
||||
import { getWebsocket } from "../websocket";
|
||||
import { callNotifikace } from "../notifikace";
|
||||
import { AddChoiceData, ChangeDepartureTimeData, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen";
|
||||
import { AddChoiceData, ChangeDepartureTimeData, MealSlot, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen";
|
||||
|
||||
|
||||
// RateLimit na refresh endpoint
|
||||
@@ -69,11 +69,21 @@ const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"]
|
||||
return dayIndex;
|
||||
}
|
||||
|
||||
const parseSlot = (body: Record<string, any>): MealSlot | undefined => {
|
||||
const slot = body?.slot;
|
||||
if (slot != null && slot !== MealSlot.OBED && slot !== MealSlot.EXTRA) {
|
||||
throw Error(`Neplatný slot: ${slot}`);
|
||||
}
|
||||
return slot ?? undefined;
|
||||
};
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
const trusted = getTrusted(parseToken(req));
|
||||
let slot: MealSlot | undefined;
|
||||
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
|
||||
let date = undefined;
|
||||
if (req.body.dayIndex != null) {
|
||||
let dayIndex;
|
||||
@@ -85,7 +95,7 @@ router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, r
|
||||
date = getDateForWeekIndex(dayIndex);
|
||||
}
|
||||
try {
|
||||
const data = await addChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
|
||||
const data = await addChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date, slot);
|
||||
getWebsocket().emit("message", data);
|
||||
return res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
@@ -94,6 +104,8 @@ router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, r
|
||||
router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["body"]>, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
const trusted = getTrusted(parseToken(req));
|
||||
let slot: MealSlot | undefined;
|
||||
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
|
||||
let date = undefined;
|
||||
if (req.body.dayIndex != null) {
|
||||
let dayIndex;
|
||||
@@ -105,7 +117,7 @@ router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["bo
|
||||
date = getDateForWeekIndex(dayIndex);
|
||||
}
|
||||
try {
|
||||
const data = await removeChoices(login, trusted, req.body.locationKey, date);
|
||||
const data = await removeChoices(login, trusted, req.body.locationKey, date, slot);
|
||||
getWebsocket().emit("message", data);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
@@ -114,6 +126,8 @@ router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["bo
|
||||
router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body"]>, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
const trusted = getTrusted(parseToken(req));
|
||||
let slot: MealSlot | undefined;
|
||||
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
|
||||
let date = undefined;
|
||||
if (req.body.dayIndex != null) {
|
||||
let dayIndex;
|
||||
@@ -125,7 +139,7 @@ router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body
|
||||
date = getDateForWeekIndex(dayIndex);
|
||||
}
|
||||
try {
|
||||
const data = await removeChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
|
||||
const data = await removeChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date, slot);
|
||||
getWebsocket().emit("message", data);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
@@ -135,6 +149,8 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
|
||||
const login = getLogin(parseToken(req));
|
||||
const trusted = getTrusted(parseToken(req));
|
||||
const note = req.body.note;
|
||||
let slot: MealSlot | undefined;
|
||||
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
|
||||
try {
|
||||
if (note && note.length > 70) {
|
||||
throw Error("Poznámka může mít maximálně 70 znaků");
|
||||
@@ -149,7 +165,7 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
|
||||
}
|
||||
date = getDateForWeekIndex(dayIndex);
|
||||
}
|
||||
const data = await updateNote(login, trusted, note, date);
|
||||
const data = await updateNote(login, trusted, note, date, slot);
|
||||
getWebsocket().emit("message", data);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
@@ -184,8 +200,10 @@ router.post("/jdemeObed", async (req, res, next) => {
|
||||
|
||||
router.post("/updateBuyer", async (req, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
let slot: MealSlot | undefined;
|
||||
try { slot = parseSlot(req.body ?? {}); } catch (e: any) { return res.status(400).json({ error: e.message }); }
|
||||
try {
|
||||
const data = await updateBuyer(login);
|
||||
const data = await updateBuyer(login, slot);
|
||||
getWebsocket().emit("message", data);
|
||||
res.status(200).json({});
|
||||
} catch (e: any) { next(e) }
|
||||
|
||||
@@ -2,7 +2,7 @@ import express, { Request } from "express";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
import { getNotificationSettings, saveNotificationSettings } from "../notifikace";
|
||||
import { subscribePush, unsubscribePush, getVapidPublicKey, findLoginByEndpoint } from "../pushReminder";
|
||||
import { subscribePush, unsubscribePush, getVapidPublicKey } from "../pushReminder";
|
||||
import { addChoice } from "../service";
|
||||
import { getWebsocket } from "../websocket";
|
||||
import { UpdateNotificationSettingsData } from "../../../types";
|
||||
@@ -66,17 +66,10 @@ router.post("/push/unsubscribe", async (req, res, next) => {
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
/** Rychlá akce z push notifikace — nastaví volbu bez JWT (identita přes subscription endpoint). */
|
||||
/** Rychlá akce z push notifikace — nastaví volbu NEOBEDVAM pro přihlášeného uživatele. */
|
||||
router.post("/push/quickChoice", async (req, res, next) => {
|
||||
try {
|
||||
const { endpoint } = req.body;
|
||||
if (!endpoint) {
|
||||
return res.status(400).json({ error: "Nebyl předán endpoint" });
|
||||
}
|
||||
const login = await findLoginByEndpoint(endpoint);
|
||||
if (!login) {
|
||||
return res.status(404).json({ error: "Subscription nenalezena" });
|
||||
}
|
||||
const login = getLogin(parseToken(req));
|
||||
const data = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined);
|
||||
getWebsocket().emit("message", data);
|
||||
res.status(200).json({});
|
||||
|
||||
+64
-56
@@ -3,11 +3,16 @@ import getStorage from "./storage";
|
||||
import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova, StaleWeekError } from "./restaurants";
|
||||
import { getTodayMock } from "./mock";
|
||||
import { removeAllUserPizzas } from "./pizza";
|
||||
import { ClientData, DepartureTime, LunchChoice, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
|
||||
import { ClientData, DepartureTime, LunchChoice, MealSlot, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
|
||||
|
||||
const storage = getStorage();
|
||||
const MENU_PREFIX = 'menu';
|
||||
|
||||
function getDataKey(date: Date, slot?: MealSlot): string {
|
||||
const base = formatDate(date);
|
||||
return slot === MealSlot.EXTRA ? `${base}_extra` : base;
|
||||
}
|
||||
|
||||
/** Vrátí dnešní datum, případně fiktivní datum pro účely vývoje a testování. */
|
||||
export function getToday(): Date {
|
||||
if (process.env.MOCK_DATA === 'true') {
|
||||
@@ -43,14 +48,16 @@ export function getEmptyData(date?: Date): ClientData {
|
||||
/**
|
||||
* Vrátí veškerá klientská data pro předaný den, nebo aktuální den, pokud není předán.
|
||||
*/
|
||||
export async function getData(date?: Date): Promise<ClientData> {
|
||||
const clientData = await getClientData(date);
|
||||
clientData.menus = {
|
||||
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
|
||||
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
|
||||
TECHTOWER: await getRestaurantMenu('TECHTOWER', date),
|
||||
ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
|
||||
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
|
||||
export async function getData(date?: Date, slot?: MealSlot): Promise<ClientData> {
|
||||
const clientData = await getClientData(date, slot);
|
||||
if (slot !== MealSlot.EXTRA) {
|
||||
clientData.menus = {
|
||||
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
|
||||
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
|
||||
TECHTOWER: await getRestaurantMenu('TECHTOWER', date),
|
||||
ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
|
||||
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
|
||||
}
|
||||
}
|
||||
return clientData;
|
||||
}
|
||||
@@ -290,8 +297,8 @@ function generateMenuWarnings(menu: RestaurantDayMenu): string[] {
|
||||
*
|
||||
* @param date datum
|
||||
*/
|
||||
export async function initIfNeeded(date?: Date) {
|
||||
const usedDate = formatDate(date ?? getToday());
|
||||
export async function initIfNeeded(date?: Date, slot?: MealSlot) {
|
||||
const usedDate = getDataKey(date ?? getToday(), slot);
|
||||
const hasData = await storage.hasData(usedDate);
|
||||
if (!hasData) {
|
||||
await storage.setData(usedDate, getEmptyData(date || getToday()));
|
||||
@@ -307,9 +314,9 @@ 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) {
|
||||
const selectedDay = formatDate(date ?? getToday());
|
||||
let data = await getClientData(date);
|
||||
export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date, slot?: MealSlot) {
|
||||
const selectedDay = getDataKey(date ?? getToday(), slot);
|
||||
let data = await getClientData(date, slot);
|
||||
validateTrusted(data, login, trusted);
|
||||
if (locationKey in data.choices) {
|
||||
if (data.choices[locationKey] && login in data.choices[locationKey]) {
|
||||
@@ -334,9 +341,9 @@ 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) {
|
||||
const selectedDay = formatDate(date ?? getToday());
|
||||
let data = await getClientData(date);
|
||||
export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date, slot?: MealSlot) {
|
||||
const selectedDay = getDataKey(date ?? getToday(), slot);
|
||||
let data = await getClientData(date, slot);
|
||||
validateTrusted(data, login, trusted);
|
||||
if (locationKey in data.choices) {
|
||||
if (data.choices[locationKey] && login in data.choices[locationKey]) {
|
||||
@@ -357,9 +364,9 @@ 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?: LunchChoice, slot?: MealSlot) {
|
||||
const usedDate = date ?? getToday();
|
||||
let data = await getClientData(usedDate);
|
||||
let data = await getClientData(usedDate, slot);
|
||||
for (const key of Object.keys(data.choices)) {
|
||||
const locationKey = key as LunchChoice;
|
||||
if (ignoredLocationKey != null && ignoredLocationKey == locationKey) {
|
||||
@@ -370,7 +377,7 @@ async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocation
|
||||
if (Object.keys(data.choices[locationKey]).length === 0) {
|
||||
delete data.choices[locationKey];
|
||||
}
|
||||
await storage.setData(formatDate(usedDate), data);
|
||||
await storage.setData(getDataKey(usedDate, slot), data);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
@@ -409,41 +416,43 @@ 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: LunchChoice, foodIndex?: number, date?: Date, slot?: MealSlot) {
|
||||
const usedDate = date ?? getToday();
|
||||
await initIfNeeded(usedDate);
|
||||
let data = await getClientData(usedDate);
|
||||
await initIfNeeded(usedDate, slot);
|
||||
let data = await getClientData(usedDate, slot);
|
||||
validateTrusted(data, login, trusted);
|
||||
await validateFoodIndex(locationKey, foodIndex, date);
|
||||
|
||||
// Pokud uživatel měl vybranou PIZZA a mění na něco jiného
|
||||
const hadPizzaChoice = data.choices.PIZZA && login in data.choices.PIZZA;
|
||||
if (hadPizzaChoice && locationKey !== LunchChoice.PIZZA && foodIndex == null) {
|
||||
// Kontrola, zda existuje Pizza day a uživatel je jeho zakladatel
|
||||
if (data.pizzaDay && data.pizzaDay.creator === login) {
|
||||
// Pokud Pizza day není ve stavu CREATED, nelze změnit volbu
|
||||
if (data.pizzaDay.state !== PizzaDayState.CREATED) {
|
||||
throw new PizzaDayConflictError(
|
||||
`Nelze změnit volbu. Pizza day je ve stavu "${data.pizzaDay.state}" a musí být nejprve dokončen nebo smazán.`
|
||||
);
|
||||
if (!slot || slot === MealSlot.OBED) {
|
||||
// Pokud uživatel měl vybranou PIZZA a mění na něco jiného
|
||||
const hadPizzaChoice = data.choices.PIZZA && login in data.choices.PIZZA;
|
||||
if (hadPizzaChoice && locationKey !== LunchChoice.PIZZA && foodIndex == null) {
|
||||
// Kontrola, zda existuje Pizza day a uživatel je jeho zakladatel
|
||||
if (data.pizzaDay && data.pizzaDay.creator === login) {
|
||||
// Pokud Pizza day není ve stavu CREATED, nelze změnit volbu
|
||||
if (data.pizzaDay.state !== PizzaDayState.CREATED) {
|
||||
throw new PizzaDayConflictError(
|
||||
`Nelze změnit volbu. Pizza day je ve stavu "${data.pizzaDay.state}" a musí být nejprve dokončen nebo smazán.`
|
||||
);
|
||||
}
|
||||
// Pizza day je ve stavu CREATED - bude smazán frontendem po potvrzení uživatelem
|
||||
// (frontend volá nejprve deletePizzaDay, pak teprve addChoice)
|
||||
}
|
||||
// Pizza day je ve stavu CREATED - bude smazán frontendem po potvrzení uživatelem
|
||||
// (frontend volá nejprve deletePizzaDay, pak teprve addChoice)
|
||||
}
|
||||
|
||||
// Smažeme pizzy uživatele (pokud Pizza day nebyl založen tímto uživatelem,
|
||||
// nebo byl již smazán frontendem)
|
||||
await removeAllUserPizzas(login, usedDate);
|
||||
// Znovu načteme data, protože removeAllUserPizzas je upravila
|
||||
data = await getClientData(usedDate);
|
||||
// Smažeme pizzy uživatele (pokud Pizza day nebyl založen tímto uživatelem,
|
||||
// nebo byl již smazán frontendem)
|
||||
await removeAllUserPizzas(login, usedDate);
|
||||
// Znovu načteme data, protože removeAllUserPizzas je upravila
|
||||
data = await getClientData(usedDate, slot);
|
||||
}
|
||||
}
|
||||
|
||||
// Pokud měníme pouze lokaci, mažeme případné předchozí
|
||||
if (foodIndex == null) {
|
||||
data = await removeChoiceIfPresent(login, usedDate);
|
||||
data = await removeChoiceIfPresent(login, usedDate, undefined, slot);
|
||||
} else {
|
||||
// Mažeme případné ostatní volby (měla by být maximálně jedna)
|
||||
data = await removeChoiceIfPresent(login, usedDate, locationKey);
|
||||
data = await removeChoiceIfPresent(login, usedDate, locationKey, slot);
|
||||
}
|
||||
// TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce
|
||||
data.choices[locationKey] ??= {};
|
||||
@@ -459,8 +468,7 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
|
||||
if (foodIndex != null && !data.choices[locationKey][login].selectedFoods?.includes(foodIndex)) {
|
||||
data.choices[locationKey][login].selectedFoods?.push(foodIndex);
|
||||
}
|
||||
const selectedDate = formatDate(usedDate);
|
||||
await storage.setData(selectedDate, data);
|
||||
await storage.setData(getDataKey(usedDate, slot), data);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -498,10 +506,10 @@ async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, d
|
||||
* @param note poznámka
|
||||
* @param date datum, ke kterému se volba vztahuje
|
||||
*/
|
||||
export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date) {
|
||||
export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date, slot?: MealSlot) {
|
||||
const usedDate = date ?? getToday();
|
||||
await initIfNeeded(usedDate);
|
||||
let data = await getClientData(usedDate);
|
||||
await initIfNeeded(usedDate, slot);
|
||||
let data = await getClientData(usedDate, slot);
|
||||
validateTrusted(data, login, trusted);
|
||||
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
|
||||
if (userEntry) {
|
||||
@@ -510,8 +518,7 @@ export async function updateNote(login: string, trusted: boolean, note?: string,
|
||||
} else {
|
||||
userEntry[1][login].note = note;
|
||||
}
|
||||
const selectedDate = formatDate(usedDate);
|
||||
await storage.setData(selectedDate, data);
|
||||
await storage.setData(getDataKey(usedDate, slot), data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -537,7 +544,7 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
|
||||
}
|
||||
found[login].departureTime = time;
|
||||
}
|
||||
await storage.setData(formatDate(usedDate), clientData);
|
||||
await storage.setData(getDataKey(usedDate), clientData);
|
||||
}
|
||||
return clientData;
|
||||
}
|
||||
@@ -548,15 +555,15 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
*/
|
||||
export async function updateBuyer(login: string) {
|
||||
export async function updateBuyer(login: string, slot?: MealSlot) {
|
||||
const usedDate = getToday();
|
||||
let clientData = await getClientData(usedDate);
|
||||
let clientData = await getClientData(usedDate, slot);
|
||||
const userEntry = clientData.choices?.['OBJEDNAVAM']?.[login];
|
||||
if (!userEntry) {
|
||||
throw new Error("Nelze nastavit objednatele pro uživatele s jinou volbou než \"Budu objednávat\"");
|
||||
}
|
||||
userEntry.isBuyer = !(userEntry.isBuyer || false);
|
||||
await storage.setData(formatDate(usedDate), clientData);
|
||||
await storage.setData(getDataKey(usedDate, slot), clientData);
|
||||
return clientData;
|
||||
}
|
||||
|
||||
@@ -566,12 +573,13 @@ export async function updateBuyer(login: string) {
|
||||
* @param date datum pro který vrátit data, pokud není vyplněno, je použit dnešní den
|
||||
* @returns data pro klienta
|
||||
*/
|
||||
export async function getClientData(date?: Date): Promise<ClientData> {
|
||||
export async function getClientData(date?: Date, slot?: MealSlot): Promise<ClientData> {
|
||||
const targetDate = date ?? getToday();
|
||||
const dateString = formatDate(targetDate);
|
||||
const dateString = getDataKey(targetDate, slot);
|
||||
const clientData = await storage.getData<ClientData>(dateString) || getEmptyData(date);
|
||||
return {
|
||||
...clientData,
|
||||
todayDayIndex: getDayOfWeekIndex(getToday()),
|
||||
...(slot === MealSlot.EXTRA ? { slot: MealSlot.EXTRA } : {}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
const mockStorageData = new Map<string, any>();
|
||||
jest.mock('../storage', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
hasData: async (key: string) => mockStorageData.has(key),
|
||||
getData: async <T>(key: string) => mockStorageData.get(key) as T,
|
||||
setData: async <T>(key: string, val: T) => void mockStorageData.set(key, val),
|
||||
}),
|
||||
storageReady: Promise.resolve(),
|
||||
}));
|
||||
|
||||
import { addChoice, getData } from '../service';
|
||||
import { LunchChoice, MealSlot } from '../../../types/gen/types.gen';
|
||||
|
||||
const TODAY = new Date('2025-01-10');
|
||||
const TODAY_STR = '2025-01-10';
|
||||
const TODAY_EXTRA_STR = '2025-01-10_extra';
|
||||
|
||||
describe('MealSlot storage isolation', () => {
|
||||
beforeEach(() => {
|
||||
mockStorageData.clear();
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(TODAY);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('addChoice slot=extra writes only to _extra key, not to obed key, and returns slot=EXTRA', async () => {
|
||||
const result = await addChoice('user1', false, LunchChoice.OBJEDNAVAM, undefined, TODAY, MealSlot.EXTRA);
|
||||
expect(result.slot).toBe(MealSlot.EXTRA);
|
||||
expect(mockStorageData.has(TODAY_EXTRA_STR)).toBe(true);
|
||||
expect(mockStorageData.has(TODAY_STR)).toBe(false);
|
||||
const extraData = mockStorageData.get(TODAY_EXTRA_STR);
|
||||
expect(extraData.choices.OBJEDNAVAM?.['user1']).toBeDefined();
|
||||
});
|
||||
|
||||
test('getData slot=extra returns slot===MealSlot.EXTRA and no menus', async () => {
|
||||
await addChoice('user1', false, LunchChoice.OBJEDNAVAM, undefined, TODAY, MealSlot.EXTRA);
|
||||
const result = await getData(TODAY, MealSlot.EXTRA);
|
||||
expect(result.slot).toBe(MealSlot.EXTRA);
|
||||
expect(result.menus).toBeUndefined();
|
||||
});
|
||||
|
||||
test('addChoice slot=extra does not modify obed data even when obed has PIZZA choice', async () => {
|
||||
mockStorageData.set(TODAY_STR, {
|
||||
choices: { PIZZA: { user1: { selectedFoods: [0], trusted: false } } },
|
||||
todayDayIndex: 4,
|
||||
date: '10. 1. 2025',
|
||||
isWeekend: false,
|
||||
dayIndex: 4,
|
||||
});
|
||||
|
||||
await addChoice('user1', false, LunchChoice.OBJEDNAVAM, undefined, TODAY, MealSlot.EXTRA);
|
||||
|
||||
const obed = mockStorageData.get(TODAY_STR);
|
||||
expect(obed.choices.PIZZA?.['user1']).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user