cc09ddbd2c
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 36s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Build and push Docker image (push) Successful in 41s
CI / Notify (push) Successful in 2s
149 lines
5.3 KiB
TypeScript
149 lines
5.3 KiB
TypeScript
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[];
|
|
/** Příznak vyřešeného (zapracovaného) návrhu - nastavuje se pouze ručním zásahem do dat */
|
|
resolved?: boolean;
|
|
}
|
|
|
|
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,
|
|
resolved: suggestion.resolved ?? false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
}
|
|
if (suggestion.resolved) {
|
|
throw new Error('Pro vyřešený návrh nelze hlasovat');
|
|
}
|
|
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);
|
|
}
|