1 Commits

Author SHA1 Message Date
batmanisko aa7e2772a7 feat: večeře (extra meal slot)
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 36s
CI / Generate TypeScript types (pull_request) Successful in 1m18s
CI / Build client (push) Successful in 36s
CI / Build server (pull_request) Successful in 27s
CI / Build client (pull_request) Successful in 41s
CI / Server unit tests (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 10m26s
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)
2026-05-06 20:37:39 +02:00
6 changed files with 27 additions and 20 deletions
+10
View File
@@ -65,6 +65,16 @@ export function getVapidPublicKey(): string | undefined {
return process.env.VAPID_PUBLIC_KEY; 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. */ /** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */
async function checkAndSendReminders(): Promise<void> { async function checkAndSendReminders(): Promise<void> {
+1 -1
View File
@@ -189,7 +189,7 @@ router.post("/testPush", async (req, res, next) => {
webpush.setVapidDetails(subject, publicKey, privateKey); webpush.setVapidDetails(subject, publicKey, privateKey);
await webpush.sendNotification( await webpush.sendNotification(
entry.subscription, entry.subscription,
JSON.stringify({ title: 'Luncher test', body: 'Push notifikace fungují!', login }) JSON.stringify({ title: 'Luncher test', body: 'Push notifikace fungují!' })
); );
res.status(200).json({ ok: true }); res.status(200).json({ ok: true });
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
+5 -10
View File
@@ -82,8 +82,7 @@ const router = express.Router();
router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, res, next) => { router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req)); const trusted = getTrusted(parseToken(req));
let slot: MealSlot | undefined; const slot = parseSlot(req.body);
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
let date = undefined; let date = undefined;
if (req.body.dayIndex != null) { if (req.body.dayIndex != null) {
let dayIndex; let dayIndex;
@@ -104,8 +103,7 @@ router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, r
router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["body"]>, res, next) => { router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req)); const trusted = getTrusted(parseToken(req));
let slot: MealSlot | undefined; const slot = parseSlot(req.body);
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
let date = undefined; let date = undefined;
if (req.body.dayIndex != null) { if (req.body.dayIndex != null) {
let dayIndex; let dayIndex;
@@ -126,8 +124,7 @@ router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["bo
router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body"]>, res, next) => { router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req)); const trusted = getTrusted(parseToken(req));
let slot: MealSlot | undefined; const slot = parseSlot(req.body);
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
let date = undefined; let date = undefined;
if (req.body.dayIndex != null) { if (req.body.dayIndex != null) {
let dayIndex; let dayIndex;
@@ -149,8 +146,7 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req)); const trusted = getTrusted(parseToken(req));
const note = req.body.note; const note = req.body.note;
let slot: MealSlot | undefined; const slot = parseSlot(req.body);
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
try { try {
if (note && note.length > 70) { if (note && note.length > 70) {
throw Error("Poznámka může mít maximálně 70 znaků"); throw Error("Poznámka může mít maximálně 70 znaků");
@@ -200,8 +196,7 @@ router.post("/jdemeObed", async (req, res, next) => {
router.post("/updateBuyer", async (req, res, next) => { router.post("/updateBuyer", async (req, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
let slot: MealSlot | undefined; const slot = parseSlot(req.body ?? {});
try { slot = parseSlot(req.body ?? {}); } catch (e: any) { return res.status(400).json({ error: e.message }); }
try { try {
const data = await updateBuyer(login, slot); const data = await updateBuyer(login, slot);
getWebsocket().emit("message", data); getWebsocket().emit("message", data);
+5 -2
View File
@@ -66,10 +66,13 @@ router.post("/push/unsubscribe", async (req, res, next) => {
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
/** Rychlá akce z push notifikace — nastaví volbu NEOBEDVAM pro přihlášeného uživatele. */ /** Rychlá akce z push notifikace — nastaví volbu bez JWT (identita přes login v payloadu). */
router.post("/push/quickChoice", async (req, res, next) => { router.post("/push/quickChoice", async (req, res, next) => {
try { try {
const login = getLogin(parseToken(req)); const { login } = req.body;
if (!login) {
return res.status(400).json({ error: "Nebyl předán login" });
}
const data = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined); const data = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined);
getWebsocket().emit("message", data); getWebsocket().emit("message", data);
res.status(200).json({}); res.status(200).json({});
+4 -4
View File
@@ -59,6 +59,7 @@ export async function getData(date?: Date, slot?: MealSlot): Promise<ClientData>
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date), SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
} }
} }
if (slot === MealSlot.EXTRA) clientData.slot = MealSlot.EXTRA;
return clientData; return clientData;
} }
@@ -530,9 +531,9 @@ export async function updateNote(login: string, trusted: boolean, note?: string,
* @param time preferovaný čas odchodu * @param time preferovaný čas odchodu
* @param date datum, ke kterému se čas vztahuje * @param date datum, ke kterému se čas vztahuje
*/ */
export async function updateDepartureTime(login: string, time?: string, date?: Date) { export async function updateDepartureTime(login: string, time?: string, date?: Date, slot?: MealSlot) {
const usedDate = date ?? getToday(); const usedDate = date ?? getToday();
let clientData = await getClientData(usedDate); let clientData = await getClientData(usedDate, slot);
const found = Object.values(clientData.choices).find(location => login in location); const found = Object.values(clientData.choices).find(location => login in location);
// TODO validace, že se jedná o restauraci // TODO validace, že se jedná o restauraci
if (found) { if (found) {
@@ -544,7 +545,7 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
} }
found[login].departureTime = time; found[login].departureTime = time;
} }
await storage.setData(getDataKey(usedDate), clientData); await storage.setData(getDataKey(usedDate, slot), clientData);
} }
return clientData; return clientData;
} }
@@ -580,6 +581,5 @@ export async function getClientData(date?: Date, slot?: MealSlot): Promise<Clien
return { return {
...clientData, ...clientData,
todayDayIndex: getDayOfWeekIndex(getToday()), todayDayIndex: getDayOfWeekIndex(getToday()),
...(slot === MealSlot.EXTRA ? { slot: MealSlot.EXTRA } : {}),
} }
} }
+2 -3
View File
@@ -27,9 +27,8 @@ describe('MealSlot storage isolation', () => {
jest.useRealTimers(); jest.useRealTimers();
}); });
test('addChoice slot=extra writes only to _extra key, not to obed key, and returns slot=EXTRA', async () => { test('addChoice slot=extra writes only to _extra key, not to obed key', async () => {
const result = await addChoice('user1', false, LunchChoice.OBJEDNAVAM, undefined, TODAY, MealSlot.EXTRA); 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_EXTRA_STR)).toBe(true);
expect(mockStorageData.has(TODAY_STR)).toBe(false); expect(mockStorageData.has(TODAY_STR)).toBe(false);
const extraData = mockStorageData.get(TODAY_EXTRA_STR); const extraData = mockStorageData.get(TODAY_EXTRA_STR);