feat: nová stránka pro návrhy na vylepšení
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 24s
CI / Build client (push) Successful in 38s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Build and push Docker image (push) Successful in 40s
CI / Notify (push) Successful in 2s

This commit is contained in:
2026-06-05 19:15:46 +02:00
parent f28f127a92
commit 17132d4124
27 changed files with 857 additions and 515 deletions
+3
View File
@@ -0,0 +1,3 @@
[
"Nová stránka pro návrhy na vylepšení (dostupná z uživatelského menu)"
]
+2 -2
View File
@@ -14,7 +14,7 @@ import { startReminderScheduler, verifyQuickChoiceToken } from "./pushReminder";
import { storageReady } from "./storage";
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
import votingRoutes from "./routes/votingRoutes";
import suggestionRoutes from "./routes/suggestionRoutes";
import easterEggRoutes from "./routes/easterEggRoutes";
import statsRoutes from "./routes/statsRoutes";
import notificationRoutes from "./routes/notificationRoutes";
@@ -199,7 +199,7 @@ app.get("/api/data", async (req, res) => {
// Ostatní routes
app.use("/api/pizzaDay", pizzaDayRoutes);
app.use("/api/food", foodRoutes);
app.use("/api/voting", votingRoutes);
app.use("/api/suggestions", suggestionRoutes);
app.use("/api/easterEggs", easterEggRoutes);
app.use("/api/stats", statsRoutes);
app.use("/api/notifications", notificationRoutes);
+50
View File
@@ -0,0 +1,50 @@
import express, { Request } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { addSuggestion, deleteSuggestion, listSuggestions, voteSuggestion } from "../suggestions";
import { AddSuggestionData, VoteSuggestionData, DeleteSuggestionData } from "../../../types";
const router = express.Router();
router.get("/list", async (req: Request, res, next) => {
try {
const login = getLogin(parseToken(req));
const data = await listSuggestions(login);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/add", async (req: Request<{}, any, AddSuggestionData["body"]>, res, next) => {
try {
const login = getLogin(parseToken(req));
if (!req.body?.title || !req.body?.description) {
return res.status(400).json({ error: "Chybné parametry volání" });
}
const data = await addSuggestion(login, req.body.title, req.body.description);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/vote", async (req: Request<{}, any, VoteSuggestionData["body"]>, res, next) => {
try {
const login = getLogin(parseToken(req));
if (!req.body?.id || !req.body?.direction) {
return res.status(400).json({ error: "Chybné parametry volání" });
}
const data = await voteSuggestion(login, req.body.id, req.body.direction);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/delete", async (req: Request<{}, any, DeleteSuggestionData["body"]>, res, next) => {
try {
const login = getLogin(parseToken(req));
if (!req.body?.id) {
return res.status(400).json({ error: "Chybné parametry volání" });
}
const data = await deleteSuggestion(login, req.body.id);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
export default router;
-33
View File
@@ -1,33 +0,0 @@
import express, { Request } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getUserVotes, updateFeatureVote, getVotingStats } from "../voting";
import { GetVotesData, UpdateVoteData } from "../../../types";
const router = express.Router();
router.get("/getVotes", async (req: Request<{}, any, GetVotesData["body"]>, res) => {
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) => {
const login = getLogin(parseToken(req));
if (req.body?.option == null || req.body?.active == null) {
res.status(400).json({ error: "Chybné parametry volání" });
}
try {
const data = await updateFeatureVote(login, req.body.option, req.body.active);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.get("/stats", async (req, res, next) => {
try {
const data = await getVotingStats();
res.status(200).json(data);
} catch (e: any) { next(e) }
});
export default router;
+142
View File
@@ -0,0 +1,142 @@
import crypto from "crypto";
import { Suggestion, VoteDirection } from "../../types/gen/types.gen";
import getStorage from "./storage";
/** Interní reprezentace návrhu uložená ve storage (včetně seznamů hlasujících). */
interface StoredSuggestion {
id: string;
author: string;
title: string;
description: string;
createdAt: string;
/** Loginy uživatelů hlasujících PRO návrh */
upvoters: string[];
/** Loginy uživatelů hlasujících PROTI návrhu */
downvoters: string[];
}
const storage = getStorage();
const STORAGE_KEY = 'suggestions';
/** Načte interní seznam návrhů ze storage. */
async function loadSuggestions(): Promise<StoredSuggestion[]> {
return (await storage.getData<StoredSuggestion[]>(STORAGE_KEY)) ?? [];
}
/**
* Převede interní návrh na DTO pro daného uživatele - skryje seznamy hlasujících
* a doplní hlas přihlášeného uživatele a příznak vlastnictví.
*/
function toDto(suggestion: StoredSuggestion, login: string): Suggestion {
let myVote: VoteDirection | undefined;
if (suggestion.upvoters.includes(login)) {
myVote = 'up';
} else if (suggestion.downvoters.includes(login)) {
myVote = 'down';
}
return {
id: suggestion.id,
author: suggestion.author,
title: suggestion.title,
description: suggestion.description,
voteScore: suggestion.upvoters.length - suggestion.downvoters.length,
myVote,
isMine: suggestion.author === login,
};
}
/**
* Vrátí seznam návrhů jako DTO pro daného uživatele, seřazený sestupně dle skóre
* (při shodě skóre stabilně dle data vytvoření vzestupně).
*
* @param login login přihlášeného uživatele
*/
export async function listSuggestions(login: string): Promise<Suggestion[]> {
const suggestions = await loadSuggestions();
return suggestions
.map(s => toDto(s, login))
.sort((a, b) => b.voteScore - a.voteScore);
}
/**
* Přidá nový návrh. Autorovi se automaticky nastaví hlas pro.
*
* @param login login autora
* @param title název návrhu
* @param description detailní popis návrhu
* @returns aktualizovaný seznam návrhů jako DTO
*/
export async function addSuggestion(login: string, title: string, description: string): Promise<Suggestion[]> {
const trimmedTitle = title?.trim();
const trimmedDescription = description?.trim();
if (!trimmedTitle) {
throw new Error('Název návrhu nesmí být prázdný');
}
if (!trimmedDescription) {
throw new Error('Popis návrhu nesmí být prázdný');
}
const suggestions = await loadSuggestions();
suggestions.push({
id: crypto.randomUUID(),
author: login,
title: trimmedTitle,
description: trimmedDescription,
createdAt: new Date().toISOString(),
// Autor automaticky hlasuje pro svůj návrh
upvoters: [login],
downvoters: [],
});
await storage.setData(STORAGE_KEY, suggestions);
return listSuggestions(login);
}
/**
* Přepne hlas uživatele u návrhu. Klik na již aktivní směr hlas zruší,
* opačný směr stávající hlas přepíše.
*
* @param login login hlasujícího uživatele
* @param id identifikátor návrhu
* @param direction směr hlasu, na který uživatel klikl
* @returns aktualizovaný seznam návrhů jako DTO
*/
export async function voteSuggestion(login: string, id: string, direction: VoteDirection): Promise<Suggestion[]> {
const suggestions = await loadSuggestions();
const suggestion = suggestions.find(s => s.id === id);
if (!suggestion) {
throw new Error('Návrh nebyl nalezen');
}
const hadUp = suggestion.upvoters.includes(login);
const hadDown = suggestion.downvoters.includes(login);
// Nejprve odebereme případný stávající hlas uživatele
suggestion.upvoters = suggestion.upvoters.filter(l => l !== login);
suggestion.downvoters = suggestion.downvoters.filter(l => l !== login);
// Hlas přidáme pouze pokud uživatel neklikl na již aktivní směr (jinak ho jen zrušíme)
if (direction === 'up' && !hadUp) {
suggestion.upvoters.push(login);
} else if (direction === 'down' && !hadDown) {
suggestion.downvoters.push(login);
}
await storage.setData(STORAGE_KEY, suggestions);
return listSuggestions(login);
}
/**
* Smaže návrh včetně všech jeho hlasů. Smazat lze pouze vlastní návrh.
*
* @param login login uživatele požadujícího smazání
* @param id identifikátor návrhu ke smazání
* @returns aktualizovaný seznam návrhů jako DTO
*/
export async function deleteSuggestion(login: string, id: string): Promise<Suggestion[]> {
const suggestions = await loadSuggestions();
const suggestion = suggestions.find(s => s.id === id);
if (!suggestion) {
throw new Error('Návrh nebyl nalezen');
}
if (suggestion.author !== login) {
throw new Error('Smazat lze pouze vlastní návrh');
}
const filtered = suggestions.filter(s => s.id !== id);
await storage.setData(STORAGE_KEY, filtered);
return listSuggestions(login);
}
+126
View File
@@ -0,0 +1,126 @@
import { resetMemoryStorage } from '../storage/memory';
import { addSuggestion, deleteSuggestion, listSuggestions, voteSuggestion } from '../suggestions';
const AUTHOR = 'tomas';
const VOTER = 'petr';
const OTHER = 'jana';
beforeEach(() => {
resetMemoryStorage();
});
/** Pomocná funkce: vytvoří návrh a vrátí jeho id (z pohledu autora). */
async function createSuggestion(author = AUTHOR, title = 'Tmavý režim', description = 'Přidat tmavý režim aplikace') {
const list = await addSuggestion(author, title, description);
return list.find(s => s.title === title)!.id;
}
describe('addSuggestion', () => {
test('přidá návrh a autorovi nastaví hlas pro', async () => {
const list = await addSuggestion(AUTHOR, 'Tmavý režim', 'Popis');
expect(list).toHaveLength(1);
const s = list[0];
expect(s.author).toBe(AUTHOR);
expect(s.title).toBe('Tmavý režim');
expect(s.description).toBe('Popis');
expect(s.voteScore).toBe(1);
expect(s.myVote).toBe('up');
expect(s.isMine).toBe(true);
});
test('ořízne mezery a odmítne prázdný název i popis', async () => {
await expect(addSuggestion(AUTHOR, ' ', 'popis')).rejects.toThrow();
await expect(addSuggestion(AUTHOR, 'název', ' ')).rejects.toThrow();
const list = await addSuggestion(AUTHOR, ' Název ', ' Popis ');
expect(list[0].title).toBe('Název');
expect(list[0].description).toBe('Popis');
});
test('jeden uživatel může přidat více návrhů', async () => {
await addSuggestion(AUTHOR, 'První', 'popis');
const list = await addSuggestion(AUTHOR, 'Druhý', 'popis');
expect(list).toHaveLength(2);
});
});
describe('listSuggestions', () => {
test('řadí sestupně dle skóre', async () => {
const lowId = await createSuggestion(AUTHOR, 'Nízké', 'popis');
const highId = await createSuggestion(OTHER, 'Vysoké', 'popis');
// "Vysoké" dostane další hlas pro
await voteSuggestion(VOTER, highId, 'up');
const list = await listSuggestions(VOTER);
expect(list[0].id).toBe(highId);
expect(list[0].voteScore).toBe(2);
expect(list[1].id).toBe(lowId);
});
test('myVote a isMine jsou relativní k uživateli', async () => {
const id = await createSuggestion(AUTHOR);
const asAuthor = (await listSuggestions(AUTHOR))[0];
expect(asAuthor.isMine).toBe(true);
expect(asAuthor.myVote).toBe('up');
const asOther = (await listSuggestions(VOTER))[0];
expect(asOther.isMine).toBe(false);
expect(asOther.myVote).toBeUndefined();
// Seznamy hlasujících se klientovi neposílají
expect((asOther as any).upvoters).toBeUndefined();
expect(id).toBeDefined();
});
});
describe('voteSuggestion', () => {
test('hlas pro a proti od jiného uživatele', async () => {
const id = await createSuggestion();
let list = await voteSuggestion(VOTER, id, 'up');
expect(list[0].voteScore).toBe(2);
expect(list.find(s => s.id === id)!.voteScore).toBe(2);
// přehlasování z pro na proti
list = await voteSuggestion(VOTER, id, 'down');
// autor +1, voter -1 => 0
expect(list[0].voteScore).toBe(0);
expect((await listSuggestions(VOTER))[0].myVote).toBe('down');
});
test('klik na aktivní směr hlas zruší', async () => {
const id = await createSuggestion();
await voteSuggestion(VOTER, id, 'up');
const list = await voteSuggestion(VOTER, id, 'up');
// zůstává jen autorův hlas
expect(list[0].voteScore).toBe(1);
expect((await listSuggestions(VOTER))[0].myVote).toBeUndefined();
});
test('autor může svůj automatický hlas odebrat', async () => {
const id = await createSuggestion();
const list = await voteSuggestion(AUTHOR, id, 'up');
expect(list[0].voteScore).toBe(0);
expect((await listSuggestions(AUTHOR))[0].myVote).toBeUndefined();
});
test('hlasování pro neexistující návrh vyhodí chybu', async () => {
await expect(voteSuggestion(VOTER, 'neexistuje', 'up')).rejects.toThrow();
});
});
describe('deleteSuggestion', () => {
test('autor smaže svůj návrh včetně hlasů', async () => {
const id = await createSuggestion();
await voteSuggestion(VOTER, id, 'up');
const list = await deleteSuggestion(AUTHOR, id);
expect(list).toHaveLength(0);
});
test('cizí uživatel nemůže smazat návrh', async () => {
const id = await createSuggestion();
await expect(deleteSuggestion(VOTER, id)).rejects.toThrow();
expect(await listSuggestions(AUTHOR)).toHaveLength(1);
});
test('smazání neexistujícího návrhu vyhodí chybu', async () => {
await expect(deleteSuggestion(AUTHOR, 'neexistuje')).rejects.toThrow();
});
});
-77
View File
@@ -1,77 +0,0 @@
import { getUserVotes, updateFeatureVote, getVotingStats } from '../voting';
import { resetMemoryStorage } from '../storage/memory';
import { FeatureRequest } from '../../../types/gen/types.gen';
const OPT_A = FeatureRequest.STATISTICS;
const OPT_B = FeatureRequest.UI;
beforeEach(() => {
resetMemoryStorage();
});
describe('updateFeatureVote', () => {
test('přidá hlas pro nového uživatele', async () => {
const result = await updateFeatureVote('alice', OPT_A, true);
expect(result['alice']).toContain(OPT_A);
});
test('vyhodí chybu při duplicitním hlasování', async () => {
await updateFeatureVote('alice', OPT_A, true);
await expect(updateFeatureVote('alice', OPT_A, true)).rejects.toThrow('hlasovali');
});
test('odebere hlas', async () => {
await updateFeatureVote('alice', OPT_A, true);
await updateFeatureVote('alice', OPT_A, false);
const stats = await getVotingStats();
expect(stats[OPT_A] ?? 0).toBe(0);
});
test('odebrání neexistujícího hlasu je no-op', async () => {
await expect(updateFeatureVote('alice', OPT_A, false)).resolves.not.toThrow();
});
test('odebrání posledního hlasu odstraní login ze storage', async () => {
await updateFeatureVote('alice', OPT_A, true);
const data = await updateFeatureVote('alice', OPT_A, false);
expect('alice' in data).toBe(false);
});
test('vyhodí chybu po 4 hlasech', async () => {
const options = Object.values(FeatureRequest);
for (let i = 0; i < 4; i++) {
await updateFeatureVote('alice', options[i], true);
}
await expect(updateFeatureVote('alice', options[4], true)).rejects.toThrow('4');
});
});
describe('getUserVotes', () => {
test('vrátí hlasy uživatele', async () => {
await updateFeatureVote('alice', OPT_A, true);
const votes = await getUserVotes('alice');
expect(votes).toContain(OPT_A);
});
test('vrátí prázdné pole pro uživatele bez hlasů', async () => {
const votes = await getUserVotes('neexistujici');
expect(votes).toEqual([]);
});
});
describe('getVotingStats', () => {
test('vrátí agregované počty hlasů', async () => {
await updateFeatureVote('alice', OPT_A, true);
await updateFeatureVote('bob', OPT_A, true);
await updateFeatureVote('bob', OPT_B, true);
const stats = await getVotingStats();
expect(stats[OPT_A]).toBe(2);
expect(stats[OPT_B]).toBe(1);
});
test('vrátí prázdný objekt bez hlasů', async () => {
const stats = await getVotingStats();
expect(stats).toEqual({});
});
});
-76
View File
@@ -1,76 +0,0 @@
import express from 'express';
import request from 'supertest';
import bodyParser from 'body-parser';
import { generateToken } from '../auth';
import { resetMemoryStorage } from '../storage/memory';
import { FeatureRequest } from '../../../types/gen/types.gen';
import votingRouter from '../routes/votingRoutes';
const VALID_OPTION = FeatureRequest.STATISTICS;
function buildApp() {
const app = express();
app.use(bodyParser.json());
app.use('/api/voting', votingRouter);
app.use((err: any, _req: any, res: any, _next: any) => {
res.status(400).json({ error: err.message });
});
return app;
}
const TOKEN = `Bearer ${generateToken('testuser')}`;
beforeEach(() => {
resetMemoryStorage();
});
test('GET /getVotes vrátí 200 s prázdným polem pro nového uživatele', async () => {
const res = await request(buildApp())
.get('/api/voting/getVotes')
.set('Authorization', TOKEN);
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
test('GET /getVotes vrátí 401 bez tokenu', async () => {
const res = await request(buildApp()).get('/api/voting/getVotes');
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('POST /updateVote přidá hlas a vrátí 200', async () => {
const res = await request(buildApp())
.post('/api/voting/updateVote')
.set('Authorization', TOKEN)
.send({ option: VALID_OPTION, active: true });
expect(res.status).toBe(200);
});
test('POST /updateVote vrátí 400 pro chybějící parametry', async () => {
const res = await request(buildApp())
.post('/api/voting/updateVote')
.set('Authorization', TOKEN)
.send({});
expect(res.status).toBe(400);
});
test('POST /updateVote vrátí 400 při duplicitním hlasu', async () => {
const app = buildApp();
await request(app)
.post('/api/voting/updateVote')
.set('Authorization', TOKEN)
.send({ option: VALID_OPTION, active: true });
const res = await request(app)
.post('/api/voting/updateVote')
.set('Authorization', TOKEN)
.send({ option: VALID_OPTION, active: true });
expect(res.status).toBe(400);
expect(res.body.error).toContain('hlasovali');
});
test('GET /stats vrátí 200 s objektem', async () => {
const res = await request(buildApp())
.get('/api/voting/stats')
.set('Authorization', TOKEN);
expect(res.status).toBe(200);
expect(typeof res.body).toBe('object');
});
-76
View File
@@ -1,76 +0,0 @@
import { FeatureRequest, VotingStats } from "../../types/gen/types.gen";
import getStorage from "./storage";
interface VotingData {
[login: string]: FeatureRequest[],
}
export interface VotingStatsResult {
[feature: string]: number;
}
const storage = getStorage();
const STORAGE_KEY = 'voting';
/**
* Vrátí pole voleb, pro které uživatel aktuálně hlasuje.
*
* @param login login uživatele
* @returns pole voleb
*/
export async function getUserVotes(login: string) {
const data = await storage.getData<VotingData>(STORAGE_KEY);
return data?.[login] || [];
}
/**
* Aktualizuje hlas uživatele pro konkrétní volbu.
*
* @param login login uživatele
* @param option volba
* @param active příznak, zda volbu přidat nebo odebrat
* @returns aktuální data
*/
export async function updateFeatureVote(login: string, option: FeatureRequest, active: boolean): Promise<VotingData> {
let data = await storage.getData<VotingData>(STORAGE_KEY);
data ??= {};
if (!(login in data)) {
data[login] = [];
}
const index = data[login].indexOf(option);
if (index > -1) {
if (active) {
throw new Error('Pro tuto možnost jste již hlasovali');
} else {
data[login].splice(index, 1);
if (data[login].length === 0) {
delete data[login];
}
}
} else if (active) {
if (data[login].length == 4) {
throw new Error('Je možné hlasovat pro maximálně 4 možnosti');
}
data[login].push(option);
}
await storage.setData(STORAGE_KEY, data);
return data;
}
/**
* Vrátí agregované statistiky hlasování - počet hlasů pro každou funkci.
*
* @returns objekt, kde klíčem je název funkce a hodnotou počet hlasů
*/
export async function getVotingStats(): Promise<VotingStatsResult> {
const data = await storage.getData<VotingData>(STORAGE_KEY);
const stats: VotingStatsResult = {};
if (data) {
for (const votes of Object.values(data)) {
for (const feature of votes) {
stats[feature] = (stats[feature] || 0) + 1;
}
}
}
return stats;
}