import type Anthropic from "@anthropic-ai/sdk"; import prisma from "../config/database"; import { listInvoices, getInvoiceStats } from "./invoices.service"; import { listOffers } from "./offers.service"; import { listOrders } from "./orders.service"; import { listIssuedOrders } from "./issued-orders.service"; import { listProjects } from "./projects.service"; import { getBelowMinimumItems } from "./warehouse.service"; import { calcWorkedHours } from "./attendance.service"; import { resolveGrid, listPlanUsers } from "./plan.service"; /** * Odin Phase 2a — READ-ONLY tools over the existing service layer. * * Security model (see docs/superpowers/specs/2026-06-08-odin-phase2-…): * - The assistant is the USER'S DELEGATE: every handler re-checks the same * permission the equivalent route requires, against the caller's authData. * The tool list sent to the model is ALSO pre-filtered by permission, but * the handler check is the actual boundary (defense in depth). * - No write tools exist in this layer at all — a prompt-injected "create an * order" cannot do anything, because there is no tool to call. * - Handlers call the SAME service functions the routes use (or replicate a * route's plain read), so ownership/visibility logic cannot diverge. * - Outputs are deliberately compact (capped rows, plain fields): tool * results are model input and bill as tokens on every loop iteration. */ export interface AiAuthCtx { userId: number; roleName: string; permissions: string[]; } export function ctxCan(ctx: AiAuthCtx, permission: string): boolean { return ctx.roleName === "admin" || ctx.permissions.includes(permission); } /** Any-of check for tools whose route equivalent uses requireAnyPermission. */ function canUse(ctx: AiAuthCtx, permission: string | string[]): boolean { return Array.isArray(permission) ? permission.some((p) => ctxCan(ctx, p)) : ctxCan(ctx, permission); } /** Uniform list paging the tools use — first page, newest first, small. */ const LIST = { page: 1, limit: 20, skip: 0, order: "desc" as const }; const DENIED = (what: string) => `Uživatel nemá oprávnění zobrazit ${what} — odpověz mu, že na to nemá oprávnění.`; interface ToolSpec { /** * Permission whose holder may use this tool (admin bypasses). An array * means any-of — mirroring routes guarded by requireAnyPermission. */ permission: string | string[]; definition: Anthropic.Tool; handler: (input: Record, ctx: AiAuthCtx) => Promise; } const num = (v: unknown): number | undefined => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : undefined; }; const str = (v: unknown): string | undefined => typeof v === "string" && v.trim() ? v.trim() : undefined; /** * Parse "YYYY-MM-DD" → UTC-midnight instant. @db.Date columns compare by the * UTC date part, so these are the only safe filter boundaries (CLAUDE.md * dates rule #2); a local-midnight Date would shift the window a day back. */ const isoDay = (v: unknown): Date | undefined => { if (typeof v !== "string") return undefined; const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v.trim()); if (!m) return undefined; const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])); return Number.isNaN(d.getTime()) ? undefined : d; }; const addDays = (d: Date, n: number): Date => new Date(d.getTime() + n * 86400000); const dayStr = (d: Date): string => d.toISOString().slice(0, 10); /** Local wall-clock HH:MM (process TZ is Europe/Prague). */ const hhmm = (d: Date | null): string | null => d ? `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}` : null; const round2 = (n: number): number => Math.round(n * 100) / 100; const TOOLS: ToolSpec[] = [ { permission: "invoices.view", definition: { name: "list_invoices", description: "Vydané faktury (faktury, které firma vystavila zákazníkům). Vrací max 20 nejnovějších odpovídajících faktur s částkami včetně DPH. Volej při dotazech na fakturace, tržby konkrétních faktur, splatnosti, neuhrazené faktury.", input_schema: { type: "object", properties: { status: { type: "string", enum: ["draft", "issued", "overdue", "paid"], description: "Filtr stavu (vynech pro všechny)", }, month: { type: "number", description: "Měsíc vystavení 1-12" }, year: { type: "number", description: "Rok vystavení" }, search: { type: "string", description: "Hledání podle čísla faktury nebo zákazníka", }, }, }, }, handler: async (input) => { const res = await listInvoices({ ...LIST, sort: "id", search: str(input.search) ?? "", status: str(input.status), month: num(input.month), year: num(input.year), }); return { total_matching: res.total, shown: res.data.length, invoices: res.data.map((i) => ({ number: i.invoice_number, customer: i.customer_name, status: i.status, issue_date: i.issue_date, due_date: i.due_date, total_with_vat: i.total, currency: i.currency, })), }; }, }, { permission: "invoices.view", definition: { name: "get_invoice_stats", description: "Měsíční přehled vydaných faktur: zaplaceno tento měsíc, čekající a po splatnosti (vč. přepočtu do CZK) a DPH za měsíc. Volej při dotazech typu 'kolik jsme letos/tento měsíc fakturovali', 'kolik nám dluží'.", input_schema: { type: "object", properties: { month: { type: "number", description: "Měsíc 1-12 (výchozí aktuální)", }, year: { type: "number", description: "Rok (výchozí aktuální)" }, }, }, }, handler: async (input) => getInvoiceStats(num(input.month), num(input.year)), }, { permission: "invoices.view", definition: { name: "list_received_invoices", description: "Přijaté faktury (faktury od dodavatelů, výdaje firmy). Vrací max 20 nejnovějších; částky jsou VČETNĚ DPH. Volej při dotazech na výdaje, faktury od dodavatele, co je k úhradě.", input_schema: { type: "object", properties: { month: { type: "number", description: "Měsíc vystavení 1-12" }, year: { type: "number", description: "Rok vystavení" }, search: { type: "string", description: "Hledání podle dodavatele nebo čísla faktury", }, }, }, }, handler: async (input) => { // Mirrors the received-invoices route's plain read (no dedicated list // service exists); read-only, same visibility (no per-user scoping). const where: Record = {}; const month = num(input.month); const year = num(input.year); if (month && year) { where.issue_date = { gte: new Date(Date.UTC(year, month - 1, 1)), lt: new Date(Date.UTC(year, month, 1)), }; } const search = str(input.search); if (search) { where.OR = [ { supplier_name: { contains: search } }, { invoice_number: { contains: search } }, ]; } const [rows, total] = await Promise.all([ prisma.received_invoices.findMany({ where, orderBy: [{ issue_date: "desc" }, { id: "desc" }], take: 20, select: { supplier_name: true, invoice_number: true, amount: true, currency: true, issue_date: true, due_date: true, status: true, }, }), prisma.received_invoices.count({ where }), ]); return { total_matching: total, shown: rows.length, note: "Částky jsou včetně DPH (gross).", invoices: rows.map((r) => ({ supplier: r.supplier_name, number: r.invoice_number, amount_with_vat: Number(r.amount), currency: r.currency, issue_date: r.issue_date, due_date: r.due_date, status: r.status, })), }; }, }, { permission: "offers.view", definition: { name: "list_offers", description: "Nabídky (cenové nabídky zákazníkům). Vrací max 20 nejnovějších; ceny jsou BEZ DPH (nabídka není daňový doklad). Stavy: draft (koncept), active (aktivní), ordered (objednaná zákazníkem), invalidated (zneplatněná).", input_schema: { type: "object", properties: { status: { type: "string", enum: ["draft", "active", "ordered", "invalidated"], }, search: { type: "string", description: "Hledání podle čísla, projektu nebo zákazníka", }, }, }, }, handler: async (input) => { const res = await listOffers({ ...LIST, sort: "id", search: str(input.search) ?? "", status: str(input.status), }); return { total_matching: res.total, shown: res.data.length, note: "Ceny jsou bez DPH.", offers: res.data.map((o) => ({ number: o.quotation_number, customer: o.customer_name, status: o.status, project_code: o.project_code, valid_until: o.valid_until, total_excl_vat: o.total, currency: o.currency, })), }; }, }, { permission: "orders.view", definition: { name: "list_orders", description: "Přijaté objednávky (objednávky od zákazníků). Vrací max 20 nejnovějších; ceny BEZ DPH.", input_schema: { type: "object", properties: { search: { type: "string", description: "Hledání podle čísla objednávky nebo zákazníka", }, month: { type: "number", description: "Měsíc vytvoření 1-12" }, year: { type: "number", description: "Rok vytvoření" }, }, }, }, handler: async (input) => { const res = await listOrders({ ...LIST, sort: "id", search: str(input.search) ?? "", month: num(input.month), year: num(input.year), }); return { total_matching: res.total, shown: res.data.length, note: "Ceny jsou bez DPH.", orders: res.data.map((o) => ({ number: o.order_number, customer_order_number: o.customer_order_number, customer: o.customer_name, status: o.status, created_at: o.created_at, total_excl_vat: o.total, currency: o.currency, })), }; }, }, { permission: "orders.view", definition: { name: "list_issued_orders", description: "Vydané objednávky (objednávky firmy u dodavatelů, nákupy). Vrací max 20 nejnovějších; ceny BEZ DPH. Stavy: draft (koncept), sent (odeslaná), confirmed (potvrzená), completed (dokončená), cancelled (zrušená).", input_schema: { type: "object", properties: { status: { type: "string" }, search: { type: "string", description: "Hledání podle čísla nebo dodavatele", }, }, }, }, handler: async (input) => { const res = await listIssuedOrders({ ...LIST, sort: "id", search: str(input.search) ?? "", status: str(input.status), }); return { total_matching: res.total, shown: res.data.length, note: "Ceny jsou bez DPH.", issued_orders: res.data.map((o) => ({ number: o.po_number, supplier: o.supplier_name, status: o.status, order_date: o.order_date, total_excl_vat: o.total, currency: o.currency, })), }; }, }, { permission: "projects.view", definition: { name: "list_projects", description: "Projekty firmy. Vrací max 20 nejnovějších. Stavy: aktivni, dokonceny, zruseny.", input_schema: { type: "object", properties: { status: { type: "string", enum: ["aktivni", "dokonceny", "zruseny"] }, search: { type: "string", description: "Hledání podle čísla, názvu nebo zákazníka", }, }, }, }, handler: async (input) => { const res = await listProjects({ ...LIST, sort: "id", search: str(input.search) ?? "", status: str(input.status), }); return { total_matching: res.total, shown: res.data.length, projects: res.data.map((p) => ({ number: p.project_number, name: p.name, customer: p.customer_name, responsible: p.responsible_user_name, status: p.status, start_date: p.start_date, end_date: p.end_date, })), }; }, }, { permission: "customers.view", definition: { name: "list_customers", description: "Zákazníci firmy (adresy, IČO, DIČ). Vrací max 20 odpovídajících.", input_schema: { type: "object", properties: { search: { type: "string", description: "Hledání podle názvu nebo IČO", }, }, }, }, handler: async (input) => { // Mirrors the customers route's read (search on name/company_id). const search = str(input.search); const where = search ? { OR: [ { name: { contains: search } }, { company_id: { contains: search } }, ], } : {}; const [rows, total] = await Promise.all([ prisma.customers.findMany({ where, orderBy: { name: "asc" }, take: 20, select: { name: true, city: true, company_id: true, vat_id: true, }, }), prisma.customers.count({ where }), ]); return { total_matching: total, shown: rows.length, customers: rows.map((c) => ({ name: c.name, city: c.city, ico: c.company_id, dic: c.vat_id, })), }; }, }, { permission: "warehouse.view", definition: { name: "get_stock_overview", description: "Sklad: položky pod minimální zásobou, nebo vyhledání skladové položky s aktuální dostupností. Volej při dotazech na sklad, zásoby, co dochází.", input_schema: { type: "object", properties: { search: { type: "string", description: "Hledání položky podle názvu nebo kódu (vynech pro přehled položek pod minimem)", }, }, }, }, handler: async (input) => { const search = str(input.search); if (!search) { const below = await getBelowMinimumItems(); return { below_minimum_count: below.length, items: below.slice(0, 20).map((i) => ({ item_number: i.item_number, name: i.name, current_stock: i.current_stock, minimum: i.min_quantity, unit: i.unit, })), }; } // Stock math mirrors the service's total-stock rule: sum of // unconsumed batch quantities. const rows = await prisma.sklad_items.findMany({ where: { is_active: true, OR: [ { name: { contains: search } }, { item_number: { contains: search } }, ], }, take: 20, select: { item_number: true, name: true, unit: true, batches: { where: { is_consumed: false }, select: { quantity: true }, }, }, }); return { shown: rows.length, items: rows.map((r) => ({ item_number: r.item_number, name: r.name, unit: r.unit, on_stock: r.batches.reduce((s, b) => s + Number(b.quantity), 0), })), }; }, }, { // Own attendance is always visible; other users need attendance.manage — // enforced inside the handler. attendance.manage alone (HR profile // without own attendance) also unlocks the tool, like the routes. permission: ["attendance.record", "attendance.manage"], definition: { name: "get_attendance_summary", description: "Docházka: denní záznamy s odpracovanými hodinami (příchod, odchod, hodiny po odečtení pauzy) + souhrn za období. Bez user_id vrací docházku PŘIHLÁŠENÉHO uživatele; cizí docházka vyžaduje oprávnění správy docházky — user_id jiného zaměstnance zjisti nástrojem find_employee. Pro konkrétní den/dny zadej date_from a date_to (klidně stejný den), jinak month/year.", input_schema: { type: "object", properties: { date_from: { type: "string", description: "Od, YYYY-MM-DD (má přednost před month/year)", }, date_to: { type: "string", description: "Do, YYYY-MM-DD (včetně)" }, month: { type: "number", description: "Měsíc 1-12 (výchozí aktuální)", }, year: { type: "number", description: "Rok (výchozí aktuální)" }, user_id: { type: "number", description: "ID jiného uživatele (jen se správou docházky)", }, }, }, }, handler: async (input, ctx) => { const targetUserId = num(input.user_id) ?? ctx.userId; if (targetUserId !== ctx.userId && !ctxCan(ctx, "attendance.manage")) { return { error: DENIED("docházku jiných uživatelů") }; } // Window: explicit day range wins over month/year. let from = isoDay(input.date_from); let to = isoDay(input.date_to); if (from || to) { from = from ?? to!; to = to ?? from; if (from > to) [from, to] = [to, from]; // Inclusive window: to may be at most from + 61 days = 62 days. if (addDays(from, 61) < to) { return { error: "Rozsah je příliš velký — maximum je 62 dní." }; } } else { const now = new Date(); const month = num(input.month) ?? now.getMonth() + 1; const year = num(input.year) ?? now.getFullYear(); from = new Date(Date.UTC(year, month - 1, 1)); to = addDays(new Date(Date.UTC(year, month, 1)), -1); } // shift_date is @db.Date → UTC-midnight boundaries, half-open upper. const rows = await prisma.attendance.findMany({ where: { user_id: targetUserId, shift_date: { gte: from, lt: addDays(to, 1) }, }, orderBy: [{ shift_date: "asc" }, { id: "asc" }], select: { shift_date: true, arrival_time: true, departure_time: true, break_start: true, break_end: true, leave_type: true, leave_hours: true, }, }); const byType: Record = {}; let workedTotal = 0; let leaveTotal = 0; const days = rows.map((r) => { const type = (r.leave_type as string) ?? "work"; byType[type] = (byType[type] || 0) + 1; let worked: number | null = null; let leave: number | null = null; if (type === "work") { if (r.arrival_time && r.departure_time) { // Same hours math as the attendance pages (break subtracted). worked = round2( calcWorkedHours( r.arrival_time, r.departure_time, r.break_start, r.break_end, ), ); workedTotal += worked; } } else { leave = Number(r.leave_hours) || 8; leaveTotal += leave; } return { date: dayStr(r.shift_date), type, arrival: hhmm(r.arrival_time), departure: hhmm(r.departure_time), worked_hours: worked, leave_hours: leave, }; }); return { user_id: targetUserId, date_from: dayStr(from), date_to: dayStr(to), days_recorded: rows.length, days_by_type: byType, worked_hours_total: round2(workedTotal), leave_hours_total: leaveTotal, days, note: "worked_hours = příchod→odchod minus pauza. Den typu work s worked_hours null je neuzavřená směna (chybí odchod).", }; }, }, { // Employee-name resolution for the people-centric tools. The VISIBLE // POPULATION is scoped per caller permission inside the handler to match // the people-picker routes exactly (GET /plan/users, GET /trips/users, // GET /users) — the gate alone is not the boundary. trips.history grants // no picker route, so it deliberately does not unlock this tool. permission: [ "attendance.record", "attendance.manage", "trips.record", "trips.manage", "users.view", ], definition: { name: "find_employee", description: "Najde zaměstnance podle jména, příjmení nebo uživatelského jména a vrátí jeho user_id. Volej VŽDY jako první krok, když se dotaz týká konkrétního zaměstnance (docházka, kniha jízd, plán práce) a neznáš jeho user_id.", input_schema: { type: "object", properties: { search: { type: "string", description: "Jméno, příjmení nebo username (stačí část)", }, }, required: ["search"], }, }, handler: async (input, ctx) => { const search = str(input.search); if (!search) { return { error: "Zadej jméno nebo uživatelské jméno k vyhledání." }; } const parts = search.split(/\s+/).filter(Boolean); const or: Record[] = [ { first_name: { contains: search } }, { last_name: { contains: search } }, { username: { contains: search } }, ]; if (parts.length >= 2) { // "Dominik Novák" — match first+last in either order. const a = parts[0]; const b = parts.slice(1).join(" "); or.push( { AND: [ { first_name: { contains: a } }, { last_name: { contains: b } }, ], }, { AND: [ { first_name: { contains: b } }, { last_name: { contains: a } }, ], }, ); } // Population parity with the picker routes: attendance perms see the // plan users (active attendance.record role-holders, GET /plan/users), // trips perms see the drivers (active trips.record role-holders, // GET /trips/users); the full directory incl. inactive accounts only // with users.view (GET /users). Admin bypasses via ctxCan. const fullDirectory = ctxCan(ctx, "users.view"); const populations: Record[] = []; if ( ctxCan(ctx, "attendance.record") || ctxCan(ctx, "attendance.manage") ) { populations.push({ roles: { role_permissions: { some: { permissions: { name: "attendance.record" } }, }, }, }); } if (ctxCan(ctx, "trips.record") || ctxCan(ctx, "trips.manage")) { populations.push({ roles: { role_permissions: { some: { permissions: { name: "trips.record" } }, }, }, }); } if (!fullDirectory && populations.length === 0) { return { error: DENIED("seznam zaměstnanců") }; } const rows = await prisma.users.findMany({ where: fullDirectory ? { OR: or } : { AND: [{ OR: or }, { is_active: true }, { OR: populations }] }, orderBy: [{ is_active: "desc" }, { last_name: "asc" }, { id: "asc" }], take: 10, select: { id: true, first_name: true, last_name: true, username: true, is_active: true, }, }); // GET /trips/users deliberately omits usernames (login identifiers); // only the attendance picker and the users admin expose them. const showUsername = fullDirectory || ctxCan(ctx, "attendance.record") || ctxCan(ctx, "attendance.manage"); return { shown: rows.length, employees: rows.map((u) => ({ user_id: u.id, name: `${u.first_name} ${u.last_name}`.trim(), ...(showUsername ? { username: u.username } : {}), active: u.is_active !== false, })), }; }, }, { // Mirrors GET /trips: any trips permission may read; non-managers are // hard-scoped to their own trips (same rule as buildTripsWhere). permission: ["trips.record", "trips.history", "trips.manage"], definition: { name: "list_trips", description: "Kniha jízd (jízdy firemních vozidel). Vrací max 20 nejnovějších odpovídajících jízd + součty km za CELÝ filtr. Bez oprávnění správy knihy jízd vidí uživatel jen své jízdy. user_id jiného řidiče zjisti nástrojem find_employee.", input_schema: { type: "object", properties: { user_id: { type: "number", description: "ID řidiče (cizí jen se správou knihy jízd)", }, vehicle: { type: "string", description: "Hledání podle názvu vozidla nebo SPZ", }, date_from: { type: "string", description: "Od, YYYY-MM-DD (má přednost před month/year)", }, date_to: { type: "string", description: "Do, YYYY-MM-DD (včetně)" }, month: { type: "number", description: "Měsíc 1-12" }, year: { type: "number", description: "Rok" }, }, }, }, handler: async (input, ctx) => { const isManager = ctxCan(ctx, "trips.manage"); const requested = num(input.user_id); if (requested && requested !== ctx.userId && !isManager) { return { error: DENIED("knihu jízd jiných uživatelů") }; } const where: Record = {}; if (!isManager) where.user_id = ctx.userId; else if (requested) where.user_id = requested; // trip_date is @db.Date → UTC-midnight boundaries, half-open upper. let from = isoDay(input.date_from); let to = isoDay(input.date_to); if (from || to) { from = from ?? to!; to = to ?? from; if (from > to) [from, to] = [to, from]; where.trip_date = { gte: from, lt: addDays(to, 1) }; } else { const month = num(input.month); // A bare month means the current year (like the attendance tool) — // silently dropping the filter would let all-time totals masquerade // as the asked-about month. const year = num(input.year) ?? (month ? new Date().getFullYear() : undefined); if (month && year) { where.trip_date = { gte: new Date(Date.UTC(year, month - 1, 1)), lt: new Date(Date.UTC(year, month, 1)), }; } } const vehicle = str(input.vehicle); if (vehicle) { where.vehicles = { is: { OR: [ { name: { contains: vehicle } }, { spz: { contains: vehicle } }, ], }, }; } const [rows, all] = await Promise.all([ prisma.trips.findMany({ where, orderBy: [{ trip_date: "desc" }, { id: "desc" }], take: 20, select: { trip_date: true, route_from: true, route_to: true, distance: true, start_km: true, end_km: true, is_business: true, users: { select: { first_name: true, last_name: true } }, vehicles: { select: { name: true, spz: true } }, }, }), prisma.trips.findMany({ where, select: { distance: true, start_km: true, end_km: true, is_business: true, }, }), ]); // Same km coalesce as the /trips/stats endpoint: legacy rows may carry // a stored distance differing from end_km - start_km. const km = (t: { distance: unknown; start_km: unknown; end_km: unknown; }): number => t.distance != null ? Number(t.distance) : Number(t.end_km) - Number(t.start_km); let businessKm = 0; let privateKm = 0; for (const t of all) { if (t.is_business) businessKm += km(t); else privateKm += km(t); } return { total_matching: all.length, shown: rows.length, total_km: businessKm + privateKm, business_km: businessKm, private_km: privateKm, trips: rows.map((t) => ({ date: dayStr(t.trip_date), driver: `${t.users.first_name} ${t.users.last_name}`.trim(), vehicle: t.vehicles.name, spz: t.vehicles.spz, from: t.route_from, to: t.route_to, km: km(t), business: t.is_business, })), }; }, }, { // Mirrors GET /plan/grid: the plan is a shared team view readable by // anyone who records attendance — no per-user scoping by design. permission: ["attendance.record", "attendance.manage"], definition: { name: "get_work_plan", description: "Plán práce: kdo má kdy co naplánováno (projekt, dovolená, kategorie dne). Výchozí období je dnešek až +7 dní. Volitelně jen jeden zaměstnanec — jeho user_id zjisti nástrojem find_employee.", input_schema: { type: "object", properties: { date_from: { type: "string", description: "Od, YYYY-MM-DD (výchozí dnes)", }, date_to: { type: "string", description: "Do, YYYY-MM-DD včetně (výchozí od + 7 dní)", }, user_id: { type: "number", description: "Jen tento uživatel (vynech pro celý tým)", }, }, }, }, handler: async (input, ctx) => { const now = new Date(); // Local calendar day as UTC midnight (the plan module's date regime). const todayUtc = new Date( Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()), ); let from = isoDay(input.date_from) ?? todayUtc; let to = isoDay(input.date_to) ?? addDays(from, 7); if (from > to) [from, to] = [to, from]; // Inclusive window: to may be at most from + 30 days = 31 days. if (addDays(from, 30) < to) { return { error: "Rozsah je příliš velký — maximum je 31 dní." }; } const planUsers = await listPlanUsers(); const nameById = new Map(planUsers.map((u) => [u.id, u.full_name])); const requested = num(input.user_id); let userIds = planUsers.map((u) => u.id); if (requested) { userIds = [requested]; if (!nameById.has(requested)) { // Not a plan-grid user (inactive / no attendance role). The grid // route never shows these, so reading their historical plan is a // management action — attendance.manage only (admin bypasses). if (!ctxCan(ctx, "attendance.manage")) { return { error: DENIED("plán tohoto uživatele") }; } const u = await prisma.users.findUnique({ where: { id: requested }, select: { first_name: true, last_name: true }, }); if (!u) return { error: "Uživatel s tímto ID neexistuje." }; nameById.set(requested, `${u.first_name} ${u.last_name}`.trim()); } } const [cells, cats] = await Promise.all([ resolveGrid(userIds, dayStr(from), dayStr(to)), prisma.plan_categories.findMany({ select: { key: true, label: true }, }), ]); const labelByKey = new Map(cats.map((c) => [c.key, c.label])); const rows: { date: string; user: string; plan: string; project: string | null; note: string | null; }[] = []; for (const uid of userIds) { const byDate = cells[uid] ?? {}; for (const date of Object.keys(byDate)) { for (const rec of byDate[date]) { rows.push({ date, user: nameById.get(uid) ?? `Uživatel #${uid}`, plan: labelByKey.get(rec.category) ?? rec.category, project: rec.project_number ? `${rec.project_number} – ${rec.project_name ?? ""}`.trim() : (rec.project_name ?? null), note: rec.note, }); } } } rows.sort( (a, b) => a.date.localeCompare(b.date) || a.user.localeCompare(b.user, "cs"), ); const truncated = rows.length > 60; return { date_from: dayStr(from), date_to: dayStr(to), records: rows.length, truncated, plan: rows.slice(0, 60), note: "Dny bez záznamu v plánu nejsou uvedeny.", }; }, }, ]; /** Tool definitions the caller may use — filtered by their permissions. */ export function toolDefinitionsFor(ctx: AiAuthCtx): Anthropic.Tool[] { return TOOLS.filter((t) => canUse(ctx, t.permission)).map( (t) => t.definition, ); } /** * Execute one tool call AS the user. Returns the JSON-serializable result; * permission failures and handler errors come back as `{ error }` results * (is_error on the tool_result), never as thrown exceptions — the model * should relay them, not crash the loop. */ export async function executeTool( name: string, input: Record, ctx: AiAuthCtx, ): Promise<{ ok: boolean; result: unknown }> { const tool = TOOLS.find((t) => t.definition.name === name); if (!tool) return { ok: false, result: { error: `Neznámý nástroj: ${name}` } }; // The boundary check: even if the model hallucinated a tool it wasn't // given, the user's permissions decide. if (!canUse(ctx, tool.permission)) { return { ok: false, result: { error: DENIED("tato data") } }; } try { const result = await tool.handler(input, ctx); // Handlers signal in-handler denials/validation as { error } — flag them // is_error like the coarse gate does, so the model and the trace chips // treat every refusal the same way. const isError = typeof result === "object" && result !== null && "error" in result; return { ok: !isError, result }; } catch (e) { console.error(`[ai-tools] ${name} failed`, e); return { ok: false, result: { error: "Nástroj selhal — zkus to jinak nebo to uživateli sděl.", }, }; } } /** Czech display labels for the frontend tool-activity chips. */ export const TOOL_LABELS: Record = { list_invoices: "Faktury vydané", get_invoice_stats: "Statistiky fakturace", list_received_invoices: "Faktury přijaté", list_offers: "Nabídky", list_orders: "Objednávky přijaté", list_issued_orders: "Objednávky vydané", list_projects: "Projekty", list_customers: "Zákazníci", get_stock_overview: "Sklad", get_attendance_summary: "Docházka", find_employee: "Zaměstnanci", list_trips: "Kniha jízd", get_work_plan: "Plán práce", };