fix: opravy po review
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 21s
CI / Generate TypeScript types (pull_request) Successful in 47s
CI / Build server (push) Successful in 27s
CI / Server unit tests (pull_request) Successful in 20s
CI / Build server (pull_request) Successful in 27s
CI / Build client (pull_request) Successful in 40s
CI / Playwright E2E tests (pull_request) Successful in 1m20s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Notify (pull_request) Has been skipped
CI / Build client (push) Successful in 4m13s
CI / Playwright E2E tests (push) Successful in 6m7s
CI / Build and push Docker image (push) Has been skipped
CI / Notify (push) Successful in 6s
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 21s
CI / Generate TypeScript types (pull_request) Successful in 47s
CI / Build server (push) Successful in 27s
CI / Server unit tests (pull_request) Successful in 20s
CI / Build server (pull_request) Successful in 27s
CI / Build client (pull_request) Successful in 40s
CI / Playwright E2E tests (pull_request) Successful in 1m20s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Notify (pull_request) Has been skipped
CI / Build client (push) Successful in 4m13s
CI / Playwright E2E tests (push) Successful in 6m7s
CI / Build and push Docker image (push) Has been skipped
CI / Notify (push) Successful in 6s
This commit is contained in:
+31
-3
@@ -2,6 +2,7 @@ import crypto from "crypto";
|
||||
import getStorage from "./storage";
|
||||
import { getClientData, getToday, initIfNeeded } from "./service";
|
||||
import { getStores } from "./stores";
|
||||
import { removePendingQrsByGroupId } from "./pizza";
|
||||
import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember } from "../../types/gen/types.gen";
|
||||
import { formatDate } from "./utils";
|
||||
|
||||
@@ -9,7 +10,9 @@ const storage = getStorage();
|
||||
|
||||
async function getExtraData(date?: Date): Promise<ClientData> {
|
||||
await initIfNeeded(date, MealSlot.EXTRA);
|
||||
return getClientData(date, MealSlot.EXTRA);
|
||||
const data = await getClientData(date, MealSlot.EXTRA);
|
||||
data.stores = await getStores();
|
||||
return data;
|
||||
}
|
||||
|
||||
function getExtraKey(date?: Date): string {
|
||||
@@ -31,9 +34,10 @@ export async function createGroup(creatorLogin: string, name: string, date?: Dat
|
||||
throw new Error('Obchod není v seznamu povolených obchodů');
|
||||
}
|
||||
const data = await getExtraData(date);
|
||||
const canonical = stores.find(s => s.toLowerCase() === name.trim().toLowerCase())!;
|
||||
const group: OrderGroup = {
|
||||
id: crypto.randomUUID(),
|
||||
name: name.trim(),
|
||||
name: canonical,
|
||||
creatorLogin,
|
||||
state: GroupState.OPEN,
|
||||
members: { [creatorLogin]: {} },
|
||||
@@ -103,9 +107,14 @@ export async function updateGroupMember(login: string, groupId: string, targetLo
|
||||
const VALID_TRANSITIONS: Record<GroupState, GroupState[]> = {
|
||||
[GroupState.OPEN]: [GroupState.LOCKED],
|
||||
[GroupState.LOCKED]: [GroupState.OPEN, GroupState.ORDERED],
|
||||
[GroupState.ORDERED]: [],
|
||||
[GroupState.ORDERED]: [GroupState.LOCKED],
|
||||
};
|
||||
|
||||
function getCurrentHHMM(): string {
|
||||
const now = new Date();
|
||||
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export async function setGroupState(login: string, groupId: string, newState: GroupState, date?: Date): Promise<ClientData> {
|
||||
const data = await getExtraData(date);
|
||||
const group = findGroup(data, groupId);
|
||||
@@ -114,6 +123,25 @@ export async function setGroupState(login: string, groupId: string, newState: Gr
|
||||
if (!VALID_TRANSITIONS[group.state].includes(newState)) {
|
||||
throw new Error(`Nelze přejít ze stavu "${group.state}" do stavu "${newState}"`);
|
||||
}
|
||||
if (newState === GroupState.ORDERED) {
|
||||
group.orderedAt = getCurrentHHMM();
|
||||
}
|
||||
if (group.state === GroupState.ORDERED && newState === GroupState.LOCKED) {
|
||||
const memberLogins = Object.keys(group.members);
|
||||
await removePendingQrsByGroupId(memberLogins, groupId);
|
||||
group.orderedAt = undefined;
|
||||
group.deliveryAt = undefined;
|
||||
}
|
||||
group.state = newState;
|
||||
return saveExtraData(data, date);
|
||||
}
|
||||
|
||||
export async function updateGroupTimes(login: string, groupId: string, orderedAt?: string, deliveryAt?: string, date?: Date): Promise<ClientData> {
|
||||
const data = await getExtraData(date);
|
||||
const group = findGroup(data, groupId);
|
||||
if (!group) throw new Error('Skupina nebyla nalezena');
|
||||
if (group.creatorLogin !== login) throw new Error('Časy může měnit pouze zakladatel');
|
||||
if (orderedAt !== undefined) group.orderedAt = orderedAt || undefined;
|
||||
if (deliveryAt !== undefined) group.deliveryAt = deliveryAt || undefined;
|
||||
return saveExtraData(data, date);
|
||||
}
|
||||
|
||||
+19
-3
@@ -1,7 +1,7 @@
|
||||
import express from "express";
|
||||
import bodyParser from "body-parser";
|
||||
import cors from 'cors';
|
||||
import { getData, getDateForWeekIndex, getToday } from "./service";
|
||||
import { getData, addChoice, getDateForWeekIndex, getToday } from "./service";
|
||||
import { MealSlot } from "../../types/gen/types.gen";
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
@@ -9,8 +9,8 @@ import { getQr } from "./qr";
|
||||
import { generateToken, getLogin, verify } from "./auth";
|
||||
import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
|
||||
import { getPendingQrs } from "./pizza";
|
||||
import { initWebsocket } from "./websocket";
|
||||
import { startReminderScheduler } from "./pushReminder";
|
||||
import { initWebsocket, getWebsocket } from "./websocket";
|
||||
import { startReminderScheduler, verifyQuickChoiceToken } from "./pushReminder";
|
||||
import { storageReady } from "./storage";
|
||||
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
|
||||
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
|
||||
@@ -116,6 +116,22 @@ app.get("/api/qr", async (req, res) => {
|
||||
// Přeskočení auth pro refresh dat xd
|
||||
app.use("/api/food/refresh", refreshMetoda);
|
||||
|
||||
// Rychlá akce z push notifikace — autentizace pomocí HMAC tokenu z push payloadu (SW nemá přístup k JWT)
|
||||
app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
|
||||
try {
|
||||
const { login, token } = req.body ?? {};
|
||||
if (!login || typeof login !== 'string' || !token || typeof token !== 'string') {
|
||||
return res.status(400).json({ error: 'Chybí login nebo token' });
|
||||
}
|
||||
if (!verifyQuickChoiceToken(login, token)) {
|
||||
return res.status(403).json({ error: 'Neplatný token' });
|
||||
}
|
||||
const updatedData = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined);
|
||||
getWebsocket().emit("message", updatedData);
|
||||
res.status(200).json({});
|
||||
} catch (e: any) { next(e); }
|
||||
});
|
||||
|
||||
/** Middleware ověřující JWT token */
|
||||
app.use("/api/", (req, res, next) => {
|
||||
if (HTTP_REMOTE_USER_ENABLED) {
|
||||
|
||||
@@ -455,4 +455,18 @@ export async function dismissPendingQr(login: string, id: string): Promise<void>
|
||||
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
||||
const filtered = existing.filter(qr => qr.id !== id);
|
||||
await storage.setData(key, filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Odstraní všechny nevyřízené QR kódy patřící dané skupině objednávky.
|
||||
*/
|
||||
export async function removePendingQrsByGroupId(logins: string[], groupId: string): Promise<void> {
|
||||
for (const login of logins) {
|
||||
const key = getPendingQrKey(login);
|
||||
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
||||
const filtered = existing.filter(qr => qr.groupId !== groupId);
|
||||
if (filtered.length !== existing.length) {
|
||||
await storage.setData(key, filtered);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import webpush from 'web-push';
|
||||
import crypto from 'crypto';
|
||||
import getStorage from './storage';
|
||||
import { getClientData, getToday } from './service';
|
||||
import { getIsWeekend } from './utils';
|
||||
@@ -65,6 +66,19 @@ export function getVapidPublicKey(): string | undefined {
|
||||
return process.env.VAPID_PUBLIC_KEY;
|
||||
}
|
||||
|
||||
function generateQuickChoiceToken(login: string): string {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const secret = process.env.JWT_SECRET ?? '';
|
||||
return crypto.createHmac('sha256', secret).update(`${login}:${today}`).digest('hex');
|
||||
}
|
||||
|
||||
/** Ověří jednorázový token z push notifikace. */
|
||||
export function verifyQuickChoiceToken(login: string, token: string): boolean {
|
||||
if (!login || !token || token.length !== 64) return false;
|
||||
const expected = generateQuickChoiceToken(login);
|
||||
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(token, 'hex'));
|
||||
}
|
||||
|
||||
|
||||
/** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */
|
||||
async function checkAndSendReminders(): Promise<void> {
|
||||
@@ -115,6 +129,7 @@ async function checkAndSendReminders(): Promise<void> {
|
||||
title: 'Luncher',
|
||||
body: 'Ještě nemáte zvolený oběd!',
|
||||
login,
|
||||
token: generateQuickChoiceToken(login),
|
||||
})
|
||||
);
|
||||
lastReminded.set(login, Date.now());
|
||||
|
||||
@@ -2,7 +2,7 @@ import express, { Request } from "express";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
import { getWebsocket } from "../websocket";
|
||||
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState } from "../groups";
|
||||
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes } from "../groups";
|
||||
import { GroupState } from "../../../types/gen/types.gen";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -39,6 +39,9 @@ router.post("/addMember", async (req: Request, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, login: targetLogin } = req.body ?? {};
|
||||
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||
if (targetLogin !== undefined && (typeof targetLogin !== 'string' || targetLogin.trim() === '')) {
|
||||
return res.status(400).json({ error: 'Neplatný login uživatele' });
|
||||
}
|
||||
const target = targetLogin ?? login;
|
||||
try {
|
||||
const data = await addGroupMember(login, id, target);
|
||||
@@ -65,10 +68,26 @@ router.post("/updateMember", async (req: Request, res, next) => {
|
||||
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
|
||||
const patch: Record<string, any> = {};
|
||||
if (amount !== undefined) patch.amount = amount;
|
||||
if (note !== undefined) patch.note = note;
|
||||
if (surchargeText !== undefined) patch.surchargeText = surchargeText;
|
||||
if (surchargeAmount !== undefined) patch.surchargeAmount = surchargeAmount;
|
||||
if (amount !== undefined) {
|
||||
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) {
|
||||
return res.status(400).json({ error: 'Neplatná částka' });
|
||||
}
|
||||
patch.amount = amount;
|
||||
}
|
||||
if (note !== undefined) {
|
||||
if (typeof note !== 'string') return res.status(400).json({ error: 'Neplatná poznámka' });
|
||||
patch.note = note;
|
||||
}
|
||||
if (surchargeText !== undefined) {
|
||||
if (typeof surchargeText !== 'string') return res.status(400).json({ error: 'Neplatný text příplatku' });
|
||||
patch.surchargeText = surchargeText;
|
||||
}
|
||||
if (surchargeAmount !== undefined) {
|
||||
if (typeof surchargeAmount !== 'number' || !Number.isFinite(surchargeAmount) || surchargeAmount < 0) {
|
||||
return res.status(400).json({ error: 'Neplatná výše příplatku' });
|
||||
}
|
||||
patch.surchargeAmount = surchargeAmount;
|
||||
}
|
||||
try {
|
||||
const data = await updateGroupMember(login, id, targetLogin, patch);
|
||||
broadcastExtra(data);
|
||||
@@ -90,4 +109,22 @@ router.post("/setState", async (req: Request, res, next) => {
|
||||
} catch (e: any) { next(e); }
|
||||
});
|
||||
|
||||
router.post("/updateTimes", async (req: Request, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, orderedAt, deliveryAt } = req.body ?? {};
|
||||
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||
const timeRegex = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
if (orderedAt !== undefined && orderedAt !== '' && !timeRegex.test(orderedAt)) {
|
||||
return res.status(400).json({ error: 'Neplatný formát času objednání (očekáváno HH:MM)' });
|
||||
}
|
||||
if (deliveryAt !== undefined && deliveryAt !== '' && !timeRegex.test(deliveryAt)) {
|
||||
return res.status(400).json({ error: 'Neplatný formát času doručení (očekáváno HH:MM)' });
|
||||
}
|
||||
try {
|
||||
const data = await updateGroupTimes(login, id, orderedAt, deliveryAt);
|
||||
broadcastExtra(data);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,8 +3,6 @@ import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
import { getNotificationSettings, saveNotificationSettings } from "../notifikace";
|
||||
import { subscribePush, unsubscribePush, getVapidPublicKey } from "../pushReminder";
|
||||
import { addChoice } from "../service";
|
||||
import { getWebsocket } from "../websocket";
|
||||
import { UpdateNotificationSettingsData } from "../../../types";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -66,14 +64,4 @@ router.post("/push/unsubscribe", async (req, res, next) => {
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
/** 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 login = getLogin(parseToken(req));
|
||||
const data = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined);
|
||||
getWebsocket().emit("message", data);
|
||||
res.status(200).json({});
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -14,7 +14,7 @@ const router = express.Router();
|
||||
router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
try {
|
||||
const { recipients, bankAccount, bankAccountHolder } = req.body;
|
||||
const { recipients, bankAccount, bankAccountHolder, groupId } = req.body;
|
||||
|
||||
if (!recipients || !Array.isArray(recipients) || recipients.length === 0) {
|
||||
return res.status(400).json({ error: "Nebyl předán seznam příjemců" });
|
||||
@@ -55,6 +55,7 @@ router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, r
|
||||
creator: login,
|
||||
totalPrice: recipient.amount,
|
||||
purpose: recipient.purpose,
|
||||
...(groupId ? { groupId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user