Revize projektu: prava, vykon, runtime, portal a ARES

Prava a bezpecnost: spravce firmy uz nemuze nastavit priznak spravce
platformy ani clenstvi v cizi firme; pozvanky, konektory a automatizace
kontroluji sve pravo; cizi firma v query je 404; zivy stream posila
udalosti jen firmam, kterych se tykaji; akce nad ticketem maji kontrolu
prava za firmu ticketu a strop viditelnosti; tokeny se nelogujou; limit
pokusu na prihlaseni, kontakt a pozvanky; bezpecnostni hlavicky;
zachyceni chyb v async handlerech; timing-safe porovnani tokenu.

Vykon: audit neskenuje celou kolekci pri kazdem zapisu a konecne maze
firemni zaznamy; ticket se uklada jednou misto trikrat; zapisy do
Postgresu jsou serializovane podle ID; prava se pocitaji jednou na
request; widgety nacitaji tickety jednou; strankovani seznamu; worker
je pool misto kol; na webu udalost ze streamu neodmontuje stranku,
dotazy maji spolecny debounce a cache, ciselniky drzi typovany sklad.

Runtime: opakuji se jen chyby oznacene retryable; smycka nenarazi na
strop 50 kroku (novy strop 1000 akci); podminka nad datem funguje;
vystup MCP nastroje neprepisuje spoustec; sandbox skriptu firmy nejde
opustit; MCP session id se drzi mezi volanimi; incident z kroku patri
firme; jedno rozhodnuti o rezimu uloziste; snapshot neprepise soubor
po chybe cteni.

Refaktory: sdilene typy API v src/shared (web nic nekopiruje, osm
rozjetych tvaru sjednoceno); spolecny modul net/guard pro volani ven;
formularova vrstva ui/form; rozdeleni Connectors a FlowCanvas; jeden
helper pro firmu z query, validaci a CRUD udalosti; pomucky ctx.util
pro skripty konektoru; i18n verejneho webu vcetne anglictiny.

Nova funkce: zalozeni firmy z registru ARES v Nastaveni (IC nebo nazev,
dotazeni IC, DIC, sidla a pravni formy, vyber soucasnych statutarnich
zastupcu a prokury, ucty spravce firmy s nahradnim e-mailem
IC-poradi@placeholder.cz).

Dokumentace: zaznam v 99-zmeny.md a aktualizace 15 dalsich dokumentu.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-09 10:26:07 +02:00
co-authored by Claude Fable 5.1
parent 0c405ea55a
commit 104ae36783
215 changed files with 13226 additions and 8320 deletions
+247
View File
@@ -0,0 +1,247 @@
/**
* Klient registru ARES (ekonomicke subjekty CR).
*
* Slouzi spravci platformy k zalozeni firmy: podle IC nebo nazvu se dotahne
* nazev, DIC, adresa a pravni forma, a z verejneho rejstriku osoby, ktere
* za firmu jednaji. ARES je verejne API bez klice, takze tu neni zadny
* pristupovy udaj ani konektor.
*
* Tri volani, kazde s vlastnim tvarem odpovedi:
* - GET /ekonomicke-subjekty/{ico} zakladni udaje (registr ROS)
* - POST /ekonomicke-subjekty/vyhledat hledani podle nazvu
* - GET /ekonomicke-subjekty-vr/{ico} verejny rejstrik vcetne organu
*
* Odpovedi se nekesuji: zaklada se jedna firma jednou a ARES ma svuj limit
* pocitany na IP, ktery tenhle objem nedosahne.
*/
import { config } from '../config.js';
import { describeFetchError, readJsonLimited, tooLargeMessage } from '../net/guard.js';
/** Odpoved ARES ma desitky kB i u male firmy (historie zapisu), proto vyssi strop. */
const BODY_LIMIT = 4_000_000;
const TIMEOUT_MS = 15_000;
export class AresError extends Error {
constructor(
message: string,
/** HTTP stav od ARES, 0 = chyba spojeni. */
readonly status: number,
) {
super(message);
this.name = 'AresError';
}
}
export interface AresCompany {
ico: string;
name: string;
/** null = neni platce DPH nebo ARES DIC neuvadi. */
dic: string | null;
/** Textova adresa sidla tak, jak ji sklada ARES. */
address: string;
/** Kod pravni formy podle ciselniku ARES, napr. 112. */
legalFormCode: string;
/** Nazev pravni formy, kdyz kod zname; jinak samotny kod. */
legalForm: string;
}
export interface AresPerson {
/** Jmeno v beznem tvaru ("Jan Novak"), ARES ho vraci velkymi pismeny. */
name: string;
firstName: string;
lastName: string;
/** Organ a funkce, napr. "jednatel" nebo "clen predstavenstva". */
roles: string[];
}
/**
* Nejcastejsi pravni formy. ARES ma vlastni ciselnik na dalsim endpointu,
* ale kvuli popisku v jednom formulari ho nema smysl stahovat; neznamy kod
* se ukaze jako cislo a nic se tim nerozbije.
*/
const LEGAL_FORMS: Record<string, string> = {
'100': 'Podnikající fyzická osoba',
'101': 'Fyzická osoba podnikající dle živnostenského zákona',
'105': 'Fyzická osoba podnikající dle jiných zákonů',
'111': 'Veřejná obchodní společnost',
'112': 'Společnost s ručením omezeným',
'113': 'Komanditní společnost',
'121': 'Akciová společnost',
'141': 'Obecně prospěšná společnost',
'145': 'Společenství vlastníků jednotek',
'161': 'Ústav',
'205': 'Družstvo',
'301': 'Státní podnik',
'331': 'Příspěvková organizace',
'421': 'Odštěpný závod zahraniční právnické osoby',
'424': 'Zahraniční fyzická osoba',
'601': 'Vysoká škola',
'641': 'Školská právnická osoba',
'661': 'Veřejná výzkumná instituce',
'701': 'Spolek',
'706': 'Pobočný spolek',
'801': 'Obec',
'804': 'Kraj',
'941': 'Evropská společnost',
};
/** IC ma 8 cislic; kratsi se doplnuje nulami zleva, jak to dela i ARES. */
export function normalizeIco(value: string): string | null {
const digits = value.replace(/\s+/g, '');
if (!/^\d{1,8}$/.test(digits)) return null;
return digits.padStart(8, '0');
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : typeof value === 'number' ? String(value) : '';
}
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function list(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.map(record).filter((v): v is Record<string, unknown> => v !== null) : [];
}
async function call(path: string, init?: RequestInit): Promise<unknown> {
const url = `${config.aresBaseUrl}${path}`;
let response: Response;
try {
response = await fetch(url, {
...init,
headers: { accept: 'application/json', ...(init?.headers ?? {}) },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
} catch (err) {
const info = describeFetchError(err, 'ARES');
console.error(`[ares] ${info.detail}`);
throw new AresError(info.message, 0);
}
const body = await readJsonLimited(response, BODY_LIMIT);
if (body.tooLarge) throw new AresError(tooLargeMessage(BODY_LIMIT), response.status);
if (response.status === 404) return null;
if (!response.ok) {
// ARES vraci v tele `popis`, ktery rika vic nez samotny kod.
const reason = text(record(body.json)?.popis) || text(record(body.json)?.subKod);
throw new AresError(
`ARES vrátil HTTP ${response.status}${reason ? `: ${reason}` : ''}.`,
response.status,
);
}
if (body.json === undefined) throw new AresError('ARES vrátil odpověď, která není JSON.', response.status);
return body.json;
}
function toCompany(subject: Record<string, unknown>): AresCompany | null {
const ico = normalizeIco(text(subject.ico));
const name = text(subject.obchodniJmeno);
if (ico === null || name === '') return null;
const legalFormCode = text(subject.pravniForma);
const seat = record(subject.sidlo);
return {
ico,
name,
dic: text(subject.dic) || null,
address: text(seat?.textovaAdresa),
legalFormCode,
legalForm: LEGAL_FORMS[legalFormCode] ?? legalFormCode,
};
}
/** Firma podle IC. `null` = ARES ji nezna. */
export async function lookupCompany(ico: string): Promise<AresCompany | null> {
const normalized = normalizeIco(ico);
if (normalized === null) throw new AresError('IČ musí být 1 až 8 číslic.', 400);
const json = await call(`/ekonomicke-subjekty/${normalized}`);
const subject = record(json);
return subject ? toCompany(subject) : null;
}
/** Firmy podle casti nazvu, nejvys `limit`. */
export async function searchCompanies(name: string, limit = 10): Promise<AresCompany[]> {
const query = name.trim();
if (query.length < 2) throw new AresError('Název pro hledání je moc krátký.', 400);
const json = await call('/ekonomicke-subjekty/vyhledat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ obchodniJmeno: query, start: 0, pocet: Math.min(Math.max(limit, 1), 50) }),
});
return list(record(json)?.ekonomickeSubjekty)
.map(toCompany)
.filter((c): c is AresCompany => c !== null);
}
/** "NOVAK" -> "Novak", "DE LA CRUZ-NOVA" -> "De La Cruz-Nova". */
function properCase(value: string): string {
return value
.toLocaleLowerCase('cs-CZ')
.replace(/(^|[\s\-'])(\p{L})/gu, (_, sep: string, ch: string) => sep + ch.toLocaleUpperCase('cs-CZ'));
}
/**
* Osoby, ktere za firmu dnes jednaji: clenove statutarnich organu a prokura.
*
* Verejny rejstrik vraci celou historii vcetne vymazanych clenu, proto se
* bere jen zaznam bez `datumVymazu`. Tataz osoba muze sedet ve dvou organech,
* slucuje se podle jmena a data narozeni.
*/
export async function listCompanyPersons(ico: string): Promise<AresPerson[]> {
const normalized = normalizeIco(ico);
if (normalized === null) throw new AresError('IČ musí být 1 až 8 číslic.', 400);
const json = await call(`/ekonomicke-subjekty-vr/${normalized}`);
const records = list(record(json)?.zaznamy);
// Firma muze mit vic zaznamu (napr. po presunu k jinemu soudu), primarni je ten platny.
const primary = records.find((r) => r.primarniZaznam === true) ?? records[0];
if (!primary) return [];
const found = new Map<string, AresPerson>();
for (const organ of list(primary.statutarniOrgany)) {
if (organ.datumVymazu) continue;
const organName = text(organ.nazevOrganu);
for (const member of list(organ.clenoveOrganu)) {
if (member.datumVymazu) continue;
const person = record(member.fyzickaOsoba);
if (!person) continue;
const firstName = properCase(text(person.jmeno));
const lastName = properCase(text(person.prijmeni));
if (firstName === '' && lastName === '') continue;
const membership = record(member.clenstvi);
const role = text(record(membership?.funkce)?.nazev) || organName || 'člen orgánu';
const key = `${firstName}|${lastName}|${text(person.datumNarozeni)}`;
const existing = found.get(key);
if (existing) {
if (!existing.roles.includes(role)) existing.roles.push(role);
continue;
}
found.set(key, {
name: [text(person.titulPred), firstName, lastName].filter(Boolean).join(' '),
firstName,
lastName,
roles: [role],
});
}
}
return [...found.values()];
}
/**
* Nahradni e-mail pro osobu z ARES: registr e-maily nevede, ale ucet bez
* e-mailu neexistuje. Tvar IC-poradi je dohodnuty a portal ho pozna, aby
* mohl upozornit, ze se ma nahradit skutecnym.
*/
export const PLACEHOLDER_DOMAIN = 'placeholder.cz';
export function placeholderEmail(ico: string, index: number): string {
return `${ico}-${index}@${PLACEHOLDER_DOMAIN}`;
}
export function isPlaceholderEmail(email: string): boolean {
return email.toLowerCase().endsWith(`@${PLACEHOLDER_DOMAIN}`);
}