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:
co-authored by
Claude Fable 5.1
parent
0c405ea55a
commit
104ae36783
@@ -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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user