Five tasks: (1) foundation — SDK dep, env, ai_usage table + ai.use permission migration; (2) spend tracker + ai.service (chat, extractInvoice) with cost/budget tests; (3) /ai routes (usage, budget, chat, extract) with guard tests; (4) DashAssistant dashboard widget; (5) v2.1.0 release. Anthropic call stubbed in tests; migration apply is controller-coordinated (dev server stopped). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1275 lines
41 KiB
Markdown
1275 lines
41 KiB
Markdown
# AI Assistant Phase 1 Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add an admin-only Claude chat widget on the dashboard that can read attached received-invoice PDFs and import them into Přijaté faktury (extract → confirm → save), all bounded by a $50/month budget cap.
|
||
|
||
**Architecture:** A new `@anthropic-ai/sdk`-backed `ai.service` (model: Claude Sonnet 4.6) exposes `chat()` and `extractInvoice()`, records every call's token cost into a new `ai_usage` table, and is gated by a pre-call budget guard reading `company_settings.ai_monthly_budget_usd`. New `/api/admin/ai/*` routes (guarded by a new `ai.use` permission) drive a `DashAssistant` dashboard widget; invoice saves reuse the existing `POST /received-invoices` endpoint. No system-data tools (Phase 2).
|
||
|
||
**Tech Stack:** Fastify 5 + Prisma (MySQL), Zod 4, `@anthropic-ai/sdk`, React 18 + MUI v7, TanStack Query, Vitest (server-side, real test DB; the Anthropic call is stubbed). Spec: `docs/superpowers/specs/2026-06-08-ai-assistant-phase1-design.md`.
|
||
|
||
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`. Frontend (Task 4) gates on `tsc -b` + `build`.
|
||
|
||
> **⚠️ Migration coordination:** Task 1 writes a migration; applying it needs the dev server stopped. The controller applies it (between Task 1 and Task 2) after asking the user to stop the dev server — see the "MIGRATION APPLY" note. Tests in Tasks 2–3 require the `ai_usage` table to exist.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**Backend**
|
||
|
||
- `package.json` — add `@anthropic-ai/sdk`.
|
||
- `src/config/env.ts` — add `anthropic.apiKey`.
|
||
- `prisma/schema.prisma` — `ai_usage` model + `ai_monthly_budget_usd` on `company_settings`.
|
||
- `prisma/migrations/20260608120000_add_ai_assistant/migration.sql` — table + column + `ai.use` permission.
|
||
- `src/services/ai.service.ts` — SDK wrapper, pricing, usage recording, budget guard.
|
||
- `src/schemas/ai.schema.ts` — Zod schemas for the chat + budget bodies.
|
||
- `src/routes/admin/ai.ts` — `/usage`, `/budget`, `/chat`, `/extract-invoices`.
|
||
- `src/server.ts` — register the AI routes.
|
||
|
||
**Frontend**
|
||
|
||
- `src/admin/lib/queries/ai.ts` — React Query options/mutations.
|
||
- `src/admin/components/dashboard/DashAssistant.tsx` — the chat widget.
|
||
- `src/admin/pages/Dashboard.tsx` — render the widget under the welcome text.
|
||
|
||
**Tests**
|
||
|
||
- `src/__tests__/ai.test.ts` — spend tracker, budget guard, route guards.
|
||
|
||
---
|
||
|
||
## Task 1: Foundation — dependency, env, schema + migration
|
||
|
||
**Files:**
|
||
|
||
- Modify: `package.json` (+ `package-lock.json`)
|
||
- Modify: `src/config/env.ts`
|
||
- Modify: `prisma/schema.prisma`
|
||
- Create: `prisma/migrations/20260608120000_add_ai_assistant/migration.sql`
|
||
|
||
- [ ] **Step 1: Install the SDK**
|
||
|
||
Run: `npm install @anthropic-ai/sdk`
|
||
Expected: `package.json` dependencies gains `@anthropic-ai/sdk`, lockfile updated.
|
||
|
||
- [ ] **Step 2: Add the API key to config**
|
||
|
||
In `src/config/env.ts`, add an `anthropic` block to the `config` object (next to `nas`):
|
||
|
||
```ts
|
||
anthropic: {
|
||
apiKey: process.env.ANTHROPIC_API_KEY || "",
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 3: Add the schema models**
|
||
|
||
In `prisma/schema.prisma`, add the `ai_usage` model (place it near the other operational tables):
|
||
|
||
```prisma
|
||
model ai_usage {
|
||
id Int @id @default(autoincrement())
|
||
user_id Int?
|
||
kind String @db.VarChar(20)
|
||
model String @db.VarChar(50)
|
||
input_tokens Int @default(0)
|
||
output_tokens Int @default(0)
|
||
cost_usd Decimal @default(0) @db.Decimal(10, 6)
|
||
created_at DateTime @default(now()) @db.Timestamp(0)
|
||
|
||
@@index([created_at], map: "idx_ai_usage_created")
|
||
}
|
||
```
|
||
|
||
And add this field to the `company_settings` model (anywhere in its field list):
|
||
|
||
```prisma
|
||
ai_monthly_budget_usd Decimal? @default(50.00) @db.Decimal(10, 2)
|
||
```
|
||
|
||
- [ ] **Step 4: Regenerate the Prisma client**
|
||
|
||
Run: `npx prisma generate`
|
||
Expected: client regenerates; `prisma.ai_usage` and `company_settings.ai_monthly_budget_usd` are now available to TypeScript. (This does NOT touch the database.)
|
||
|
||
- [ ] **Step 5: Hand-write the migration**
|
||
|
||
Create `prisma/migrations/20260608120000_add_ai_assistant/migration.sql` (the folder name must sort **after** the newest existing migration folder — bump the `20260608120000` timestamp if a later one already exists). Contents:
|
||
|
||
```sql
|
||
-- AI assistant (Phase 1): usage-tracking table, monthly budget column, ai.use permission.
|
||
|
||
CREATE TABLE `ai_usage` (
|
||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||
`user_id` INTEGER NULL,
|
||
`kind` VARCHAR(20) NOT NULL,
|
||
`model` VARCHAR(50) NOT NULL,
|
||
`input_tokens` INTEGER NOT NULL DEFAULT 0,
|
||
`output_tokens` INTEGER NOT NULL DEFAULT 0,
|
||
`cost_usd` DECIMAL(10, 6) NOT NULL DEFAULT 0,
|
||
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||
PRIMARY KEY (`id`),
|
||
INDEX `idx_ai_usage_created` (`created_at`)
|
||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||
|
||
ALTER TABLE `company_settings`
|
||
ADD COLUMN `ai_monthly_budget_usd` DECIMAL(10, 2) NULL DEFAULT 50.00;
|
||
|
||
-- ai.use permission + grant to admin (INSERT IGNORE → idempotent), mirroring
|
||
-- the warehouse-permissions migration.
|
||
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
|
||
('ai.use', 'AI asistent', 'ai', 'Používat AI asistenta (chat a import faktur)', NOW());
|
||
|
||
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
|
||
SELECT r.id, p.id
|
||
FROM `roles` r
|
||
CROSS JOIN `permissions` p
|
||
WHERE r.name = 'admin'
|
||
AND p.name = 'ai.use';
|
||
```
|
||
|
||
- [ ] **Step 6: Gate (compile only — DB not yet migrated)**
|
||
|
||
Run: `npx tsc -b --noEmit` (exit 0) and `npm run build` (success). Do NOT run vitest yet — the `ai_usage` table isn't applied to the DB (the controller applies it next).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add package.json package-lock.json src/config/env.ts prisma/schema.prisma prisma/migrations
|
||
git commit -m "feat(ai): add @anthropic-ai/sdk, ai_usage table, ai.use permission"
|
||
```
|
||
|
||
---
|
||
|
||
> ### MIGRATION APPLY (controller step — between Task 1 and Task 2)
|
||
>
|
||
> Before Task 2's tests can run, the migration must be applied to the dev/test database. The controller asks the user to **stop the dev server**, then runs:
|
||
>
|
||
> ```bash
|
||
> npx prisma migrate deploy # applies the hand-written add_ai_assistant migration
|
||
> npx prisma generate
|
||
> ```
|
||
>
|
||
> Then the user restarts the dev server. (`migrate deploy` applies pending migrations without the dev-mode shadow-DB diff.) Tests in Tasks 2–3 hit the now-present `ai_usage` table.
|
||
|
||
---
|
||
|
||
## Task 2: Spend tracker + AI service
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/services/ai.service.ts`
|
||
- Test: `src/__tests__/ai.test.ts`
|
||
|
||
The cost/budget logic is pure + DB-backed (fully testable). The `chat`/`extractInvoice` SDK calls are thin and not unit-tested against the real API.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `src/__tests__/ai.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||
import prisma from "../config/database";
|
||
import {
|
||
computeCostUsd,
|
||
recordUsage,
|
||
getMonthSpendUsd,
|
||
getBudgetUsd,
|
||
assertBudgetAvailable,
|
||
isConfigured,
|
||
} from "../services/ai.service";
|
||
|
||
const KIND = "test_ai"; // marker so we only clean our own rows
|
||
|
||
beforeEach(async () => {
|
||
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
|
||
});
|
||
afterAll(async () => {
|
||
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
|
||
});
|
||
|
||
describe("ai.service cost + budget", () => {
|
||
it("computes Sonnet 4.6 cost from tokens", () => {
|
||
// 1,000,000 input @ $3 + 1,000,000 output @ $15 = $18
|
||
expect(
|
||
computeCostUsd("claude-sonnet-4-6", 1_000_000, 1_000_000),
|
||
).toBeCloseTo(18, 6);
|
||
expect(computeCostUsd("claude-sonnet-4-6", 4000, 500)).toBeCloseTo(
|
||
0.0195,
|
||
6,
|
||
);
|
||
});
|
||
|
||
it("records usage with the computed cost", async () => {
|
||
await recordUsage({
|
||
userId: null,
|
||
kind: KIND,
|
||
model: "claude-sonnet-4-6",
|
||
inputTokens: 4000,
|
||
outputTokens: 500,
|
||
});
|
||
const rows = await prisma.ai_usage.findMany({ where: { kind: KIND } });
|
||
expect(rows.length).toBe(1);
|
||
expect(Number(rows[0].cost_usd)).toBeCloseTo(0.0195, 6);
|
||
});
|
||
|
||
it("sums only the current month's spend", async () => {
|
||
await recordUsage({
|
||
userId: null,
|
||
kind: KIND,
|
||
model: "claude-sonnet-4-6",
|
||
inputTokens: 1_000_000,
|
||
outputTokens: 0,
|
||
}); // $3 this month
|
||
// An old row (last year) must NOT count.
|
||
await prisma.ai_usage.create({
|
||
data: {
|
||
kind: KIND,
|
||
model: "claude-sonnet-4-6",
|
||
input_tokens: 1_000_000,
|
||
output_tokens: 0,
|
||
cost_usd: 3,
|
||
created_at: new Date("2000-01-01T00:00:00Z"),
|
||
},
|
||
});
|
||
const spend = await getMonthSpendUsd();
|
||
expect(spend).toBeGreaterThanOrEqual(3);
|
||
expect(spend).toBeLessThan(6); // the year-2000 $3 is excluded
|
||
});
|
||
|
||
it("assertBudgetAvailable blocks at/over budget, allows under", async () => {
|
||
const budget = await getBudgetUsd();
|
||
// Under budget → null (allowed)
|
||
expect(await assertBudgetAvailable()).toBeNull();
|
||
// Push spend over budget for this month, then it must block with 402.
|
||
await prisma.ai_usage.create({
|
||
data: {
|
||
kind: KIND,
|
||
model: "claude-sonnet-4-6",
|
||
input_tokens: 0,
|
||
output_tokens: 0,
|
||
cost_usd: budget + 1,
|
||
created_at: new Date(),
|
||
},
|
||
});
|
||
const blocked = await assertBudgetAvailable();
|
||
expect(blocked).not.toBeNull();
|
||
expect(blocked?.status).toBe(402);
|
||
});
|
||
|
||
it("isConfigured reflects the API key presence", () => {
|
||
expect(typeof isConfigured()).toBe("boolean");
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the tests to verify they fail**
|
||
|
||
Run: `npx vitest run src/__tests__/ai.test.ts`
|
||
Expected: FAIL — `../services/ai.service` does not export these functions.
|
||
|
||
- [ ] **Step 3: Implement `ai.service.ts`**
|
||
|
||
Create `src/services/ai.service.ts`:
|
||
|
||
```ts
|
||
import Anthropic from "@anthropic-ai/sdk";
|
||
import prisma from "../config/database";
|
||
import { config } from "../config/env";
|
||
|
||
/** The single model this assistant uses (Phase 1). */
|
||
export const AI_MODEL = "claude-sonnet-4-6";
|
||
|
||
/** Per-token USD pricing. Sonnet 4.6 = $3 / $15 per 1M (input / output). */
|
||
const PRICING: Record<string, { input: number; output: number }> = {
|
||
"claude-sonnet-4-6": { input: 3 / 1_000_000, output: 15 / 1_000_000 },
|
||
};
|
||
|
||
const DEFAULT_BUDGET_USD = 50;
|
||
|
||
export function isConfigured(): boolean {
|
||
return !!config.anthropic.apiKey;
|
||
}
|
||
|
||
/** Lazily build the SDK client; throws a typed result upstream if unconfigured. */
|
||
function client(): Anthropic {
|
||
return new Anthropic({ apiKey: config.anthropic.apiKey });
|
||
}
|
||
|
||
export function computeCostUsd(
|
||
model: string,
|
||
inputTokens: number,
|
||
outputTokens: number,
|
||
): number {
|
||
const p = PRICING[model] ?? PRICING[AI_MODEL];
|
||
return inputTokens * p.input + outputTokens * p.output;
|
||
}
|
||
|
||
export async function recordUsage(args: {
|
||
userId: number | null;
|
||
kind: string;
|
||
model: string;
|
||
inputTokens: number;
|
||
outputTokens: number;
|
||
}): Promise<void> {
|
||
await prisma.ai_usage.create({
|
||
data: {
|
||
user_id: args.userId,
|
||
kind: args.kind,
|
||
model: args.model,
|
||
input_tokens: args.inputTokens,
|
||
output_tokens: args.outputTokens,
|
||
cost_usd: computeCostUsd(args.model, args.inputTokens, args.outputTokens),
|
||
},
|
||
});
|
||
}
|
||
|
||
function startOfMonthUtc(): Date {
|
||
const d = new Date();
|
||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
|
||
}
|
||
|
||
export async function getMonthSpendUsd(): Promise<number> {
|
||
const agg = await prisma.ai_usage.aggregate({
|
||
_sum: { cost_usd: true },
|
||
where: { created_at: { gte: startOfMonthUtc() } },
|
||
});
|
||
return Number(agg._sum.cost_usd ?? 0);
|
||
}
|
||
|
||
export async function getBudgetUsd(): Promise<number> {
|
||
const settings = await prisma.company_settings.findFirst({
|
||
select: { ai_monthly_budget_usd: true },
|
||
});
|
||
const v = settings?.ai_monthly_budget_usd;
|
||
return v == null ? DEFAULT_BUDGET_USD : Number(v);
|
||
}
|
||
|
||
export async function setBudgetUsd(value: number): Promise<void> {
|
||
const existing = await prisma.company_settings.findFirst({
|
||
select: { id: true },
|
||
});
|
||
if (existing) {
|
||
await prisma.company_settings.update({
|
||
where: { id: existing.id },
|
||
data: { ai_monthly_budget_usd: value },
|
||
});
|
||
} else {
|
||
await prisma.company_settings.create({
|
||
data: { company_name: "", ai_monthly_budget_usd: value },
|
||
});
|
||
}
|
||
}
|
||
|
||
/** Returns { error, status: 402 } when this month's spend has reached the budget. */
|
||
export async function assertBudgetAvailable(): Promise<{
|
||
error: string;
|
||
status: number;
|
||
} | null> {
|
||
const [spend, budget] = await Promise.all([
|
||
getMonthSpendUsd(),
|
||
getBudgetUsd(),
|
||
]);
|
||
if (spend >= budget) {
|
||
return { error: "Měsíční rozpočet AI byl vyčerpán", status: 402 };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export interface ChatMessage {
|
||
role: "user" | "assistant";
|
||
content: string;
|
||
}
|
||
|
||
const SYSTEM_PROMPT =
|
||
"Jsi asistent v interním firemním systému (česká firma). Odpovídej česky, stručně a věcně. " +
|
||
"Nemáš přístup k datům systému; pomáháš s obecnými dotazy a se čtením přiložených faktur.";
|
||
|
||
/** Plain chat turn. Records usage. Caller must check the budget first. */
|
||
export async function chat(
|
||
messages: ChatMessage[],
|
||
userId: number | null,
|
||
): Promise<{ reply: string }> {
|
||
const res = await client().messages.create({
|
||
model: AI_MODEL,
|
||
max_tokens: 2048,
|
||
system: SYSTEM_PROMPT,
|
||
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
||
});
|
||
await recordUsage({
|
||
userId,
|
||
kind: "chat",
|
||
model: AI_MODEL,
|
||
inputTokens: res.usage.input_tokens,
|
||
outputTokens: res.usage.output_tokens,
|
||
});
|
||
const reply = res.content
|
||
.filter((b): b is Anthropic.TextBlock => b.type === "text")
|
||
.map((b) => b.text)
|
||
.join("\n");
|
||
return { reply };
|
||
}
|
||
|
||
export interface ExtractedInvoice {
|
||
supplier_name: string;
|
||
invoice_number: string | null;
|
||
amount: number;
|
||
currency: string;
|
||
vat_rate: number;
|
||
issue_date: string | null;
|
||
due_date: string | null;
|
||
description: string | null;
|
||
}
|
||
|
||
const INVOICE_SCHEMA = {
|
||
type: "object",
|
||
properties: {
|
||
supplier_name: { type: "string" },
|
||
invoice_number: { type: ["string", "null"] },
|
||
amount: { type: "number" },
|
||
currency: { type: "string" },
|
||
vat_rate: { type: "number" },
|
||
issue_date: { type: ["string", "null"] },
|
||
due_date: { type: ["string", "null"] },
|
||
description: { type: ["string", "null"] },
|
||
},
|
||
required: ["supplier_name", "amount", "currency", "vat_rate"],
|
||
additionalProperties: false,
|
||
} as const;
|
||
|
||
/** Vision-extract the received-invoice fields from a PDF. Records usage. */
|
||
export async function extractInvoice(
|
||
pdfBuffer: Buffer,
|
||
userId: number | null,
|
||
): Promise<ExtractedInvoice> {
|
||
const res = await client().messages.create({
|
||
model: AI_MODEL,
|
||
max_tokens: 1024,
|
||
output_config: { format: { type: "json_schema", schema: INVOICE_SCHEMA } },
|
||
messages: [
|
||
{
|
||
role: "user",
|
||
content: [
|
||
{
|
||
type: "document",
|
||
source: {
|
||
type: "base64",
|
||
media_type: "application/pdf",
|
||
data: pdfBuffer.toString("base64"),
|
||
},
|
||
},
|
||
{
|
||
type: "text",
|
||
text:
|
||
"Vyčti z této přijaté faktury pole dodavatele, číslo faktury, částku (základ bez DPH), " +
|
||
"měnu (ISO kód), sazbu DPH v procentech, datum vystavení a splatnosti (YYYY-MM-DD) a krátký popis. " +
|
||
"Pokud pole chybí, vrať null.",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
await recordUsage({
|
||
userId,
|
||
kind: "extract",
|
||
model: AI_MODEL,
|
||
inputTokens: res.usage.input_tokens,
|
||
outputTokens: res.usage.output_tokens,
|
||
});
|
||
const text = res.content
|
||
.filter((b): b is Anthropic.TextBlock => b.type === "text")
|
||
.map((b) => b.text)
|
||
.join("");
|
||
return JSON.parse(text) as ExtractedInvoice;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests to verify they pass**
|
||
|
||
Run: `npx vitest run src/__tests__/ai.test.ts`
|
||
Expected: PASS (the 5 cost/budget tests). Then `npx vitest run` to confirm the full suite is green.
|
||
|
||
- [ ] **Step 5: Gate + commit**
|
||
|
||
```bash
|
||
npx tsc -b --noEmit
|
||
git add src/services/ai.service.ts src/__tests__/ai.test.ts
|
||
git commit -m "feat(ai): spend tracker + AI service (chat, extractInvoice)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Routes
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/schemas/ai.schema.ts`
|
||
- Create: `src/routes/admin/ai.ts`
|
||
- Modify: `src/server.ts` (register the route)
|
||
- Test: `src/__tests__/ai.test.ts` (append HTTP tests)
|
||
|
||
- [ ] **Step 1: Write the schemas**
|
||
|
||
Create `src/schemas/ai.schema.ts`:
|
||
|
||
```ts
|
||
import { z } from "zod";
|
||
|
||
export const AiChatSchema = z.object({
|
||
messages: z
|
||
.array(
|
||
z.object({
|
||
role: z.enum(["user", "assistant"]),
|
||
content: z.string().min(1).max(8000),
|
||
}),
|
||
)
|
||
.min(1, "Zpráva je povinná"),
|
||
});
|
||
|
||
export const AiBudgetSchema = z.object({
|
||
budget_usd: z
|
||
.union([z.number(), z.string()])
|
||
.transform((v) => Number(v))
|
||
.refine((n) => Number.isFinite(n) && n >= 0, "Neplatný rozpočet"),
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Write the route HTTP tests (append to `src/__tests__/ai.test.ts`)**
|
||
|
||
The test file already builds a Fastify app pattern in `plan.test.ts`; mirror it here. Append a describe block that registers `aiRoutes` and exercises the guards. Add the imports at the top of `ai.test.ts`:
|
||
|
||
```ts
|
||
import Fastify from "fastify";
|
||
import cookie from "@fastify/cookie";
|
||
import rateLimit from "@fastify/rate-limit";
|
||
import jwt from "jsonwebtoken";
|
||
import { config as appConfig } from "../config/env";
|
||
import { securityHeaders } from "../middleware/security";
|
||
import aiRoutes from "../routes/admin/ai";
|
||
```
|
||
|
||
Append:
|
||
|
||
```ts
|
||
describe("HTTP /api/admin/ai", () => {
|
||
let app: Awaited<ReturnType<typeof buildAiApp>>;
|
||
let adminToken: string;
|
||
let noPermToken: string;
|
||
let noPermRoleId: number;
|
||
let noPermUserId: number;
|
||
let savedKey: string;
|
||
|
||
async function buildAiApp() {
|
||
const a = Fastify({ logger: false });
|
||
await a.register(cookie);
|
||
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||
a.addHook("onRequest", securityHeaders);
|
||
await a.register(aiRoutes, { prefix: "/api/admin/ai" });
|
||
return a;
|
||
}
|
||
function token(user: {
|
||
id: number;
|
||
username: string;
|
||
roleName: string | null;
|
||
}) {
|
||
return jwt.sign(
|
||
{ sub: user.id, username: user.username, role: user.roleName },
|
||
appConfig.jwt.secret,
|
||
{ expiresIn: "15m" },
|
||
);
|
||
}
|
||
|
||
beforeAll(async () => {
|
||
savedKey = appConfig.anthropic.apiKey;
|
||
app = await buildAiApp();
|
||
const admin = await prisma.users.findFirst({
|
||
where: { roles: { name: "admin" } },
|
||
include: { roles: true },
|
||
});
|
||
if (!admin) throw new Error("admin not found");
|
||
adminToken = token({
|
||
id: admin.id,
|
||
username: admin.username,
|
||
roleName: admin.roles?.name ?? null,
|
||
});
|
||
const stamp = Date.now();
|
||
const role = await prisma.roles.create({
|
||
data: { name: `noperm_ai_${stamp}`, display_name: "No Perm AI" },
|
||
});
|
||
noPermRoleId = role.id;
|
||
const u = await prisma.users.create({
|
||
data: {
|
||
username: `noperm_ai_${stamp}`,
|
||
first_name: "No",
|
||
last_name: "Perm",
|
||
email: `noperm_ai_${stamp}@test.local`,
|
||
password_hash:
|
||
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||
role_id: role.id,
|
||
is_active: true,
|
||
},
|
||
});
|
||
noPermUserId = u.id;
|
||
noPermToken = token({
|
||
id: u.id,
|
||
username: u.username,
|
||
roleName: role.name,
|
||
});
|
||
});
|
||
afterAll(async () => {
|
||
if (app) await app.close();
|
||
(appConfig.anthropic as { apiKey: string }).apiKey = savedKey;
|
||
if (noPermUserId)
|
||
await prisma.users
|
||
.deleteMany({ where: { id: noPermUserId } })
|
||
.catch(() => {});
|
||
if (noPermRoleId)
|
||
await prisma.roles
|
||
.deleteMany({ where: { id: noPermRoleId } })
|
||
.catch(() => {});
|
||
});
|
||
|
||
it("GET /usage requires ai.use", async () => {
|
||
const res = await app.inject({
|
||
method: "GET",
|
||
url: "/api/admin/ai/usage",
|
||
headers: { Authorization: `Bearer ${noPermToken}` },
|
||
});
|
||
expect(res.statusCode).toBe(403);
|
||
});
|
||
|
||
it("GET /usage returns spend + budget for an admin", async () => {
|
||
const res = await app.inject({
|
||
method: "GET",
|
||
url: "/api/admin/ai/usage",
|
||
headers: { Authorization: `Bearer ${adminToken}` },
|
||
});
|
||
expect(res.statusCode).toBe(200);
|
||
const body = res.json();
|
||
expect(typeof body.data.budget_usd).toBe("number");
|
||
expect(typeof body.data.month_spend_usd).toBe("number");
|
||
});
|
||
|
||
it("POST /chat returns 503 when AI is not configured", async () => {
|
||
(appConfig.anthropic as { apiKey: string }).apiKey = "";
|
||
const res = await app.inject({
|
||
method: "POST",
|
||
url: "/api/admin/ai/chat",
|
||
headers: {
|
||
Authorization: `Bearer ${adminToken}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
payload: { messages: [{ role: "user", content: "ahoj" }] },
|
||
});
|
||
expect(res.statusCode).toBe(503);
|
||
(appConfig.anthropic as { apiKey: string }).apiKey = savedKey;
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run the new tests to verify they fail**
|
||
|
||
Run: `npx vitest run src/__tests__/ai.test.ts`
|
||
Expected: FAIL — `../routes/admin/ai` does not exist.
|
||
|
||
- [ ] **Step 4: Implement the routes**
|
||
|
||
Create `src/routes/admin/ai.ts`:
|
||
|
||
```ts
|
||
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||
import multipart from "@fastify/multipart";
|
||
import { requirePermission } from "../../middleware/auth";
|
||
import { success, error } from "../../utils/response";
|
||
import { parseBody } from "../../schemas/common";
|
||
import { AiChatSchema, AiBudgetSchema } from "../../schemas/ai.schema";
|
||
import { logAudit } from "../../services/audit";
|
||
import { config } from "../../config/env";
|
||
import {
|
||
isConfigured,
|
||
assertBudgetAvailable,
|
||
getMonthSpendUsd,
|
||
getBudgetUsd,
|
||
setBudgetUsd,
|
||
chat,
|
||
extractInvoice,
|
||
} from "../../services/ai.service";
|
||
|
||
export default async function aiRoutes(app: FastifyInstance): Promise<void> {
|
||
await app.register(multipart, {
|
||
limits: { fileSize: config.nas.maxUploadSize },
|
||
});
|
||
|
||
// GET /api/admin/ai/usage — current-month spend + budget
|
||
app.get(
|
||
"/usage",
|
||
{ preHandler: requirePermission("ai.use") },
|
||
async (_request: FastifyRequest, reply: FastifyReply) => {
|
||
const [month_spend_usd, budget_usd] = await Promise.all([
|
||
getMonthSpendUsd(),
|
||
getBudgetUsd(),
|
||
]);
|
||
return success(reply, {
|
||
configured: isConfigured(),
|
||
month_spend_usd: Math.round(month_spend_usd * 1_000_000) / 1_000_000,
|
||
budget_usd,
|
||
remaining_usd: Math.max(0, budget_usd - month_spend_usd),
|
||
});
|
||
},
|
||
);
|
||
|
||
// PUT /api/admin/ai/budget — set the monthly budget (admins have ai.use)
|
||
app.put(
|
||
"/budget",
|
||
{ preHandler: requirePermission("ai.use") },
|
||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||
const body = parseBody(AiBudgetSchema, request.body);
|
||
if ("error" in body) return error(reply, body.error, 400);
|
||
await setBudgetUsd(body.data!.budget_usd);
|
||
await logAudit({
|
||
request,
|
||
authData: request.authData,
|
||
action: "update",
|
||
entityType: "company_settings",
|
||
description: `Změněn měsíční rozpočet AI na $${body.data!.budget_usd}`,
|
||
});
|
||
return success(
|
||
reply,
|
||
{ budget_usd: body.data!.budget_usd },
|
||
200,
|
||
"Rozpočet uložen",
|
||
);
|
||
},
|
||
);
|
||
|
||
// POST /api/admin/ai/chat
|
||
app.post(
|
||
"/chat",
|
||
{ preHandler: requirePermission("ai.use") },
|
||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||
if (!isConfigured()) return error(reply, "AI není nakonfigurováno", 503);
|
||
const body = parseBody(AiChatSchema, request.body);
|
||
if ("error" in body) return error(reply, body.error, 400);
|
||
const budgetErr = await assertBudgetAvailable();
|
||
if (budgetErr) return error(reply, budgetErr.error, budgetErr.status);
|
||
const { reply: text } = await chat(
|
||
body.data!.messages,
|
||
request.authData!.userId,
|
||
);
|
||
const remaining_usd = Math.max(
|
||
0,
|
||
(await getBudgetUsd()) - (await getMonthSpendUsd()),
|
||
);
|
||
return success(reply, { reply: text, remaining_usd });
|
||
},
|
||
);
|
||
|
||
// POST /api/admin/ai/extract-invoices — multipart PDF files
|
||
app.post(
|
||
"/extract-invoices",
|
||
{ preHandler: requirePermission("ai.use") },
|
||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||
if (!isConfigured()) return error(reply, "AI není nakonfigurováno", 503);
|
||
const budgetErr = await assertBudgetAvailable();
|
||
if (budgetErr) return error(reply, budgetErr.error, budgetErr.status);
|
||
|
||
const parts = request.parts();
|
||
const results: Array<{
|
||
file_name: string;
|
||
fields?: unknown;
|
||
error?: string;
|
||
}> = [];
|
||
for await (const part of parts) {
|
||
if (part.type !== "file") continue;
|
||
const buf = await part.toBuffer();
|
||
try {
|
||
const fields = await extractInvoice(buf, request.authData!.userId);
|
||
results.push({ file_name: part.filename || "faktura.pdf", fields });
|
||
} catch (e) {
|
||
request.log.error(e, "extractInvoice failed");
|
||
results.push({
|
||
file_name: part.filename || "faktura.pdf",
|
||
error: "Nepodařilo se přečíst fakturu",
|
||
});
|
||
}
|
||
// Re-check the budget between files so one batch can't blow far past it.
|
||
const over = await assertBudgetAvailable();
|
||
if (over) break;
|
||
}
|
||
if (results.length === 0)
|
||
return error(reply, "Nebyl nahrán žádný soubor", 400);
|
||
return success(reply, { invoices: results });
|
||
},
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Register the route in `server.ts`**
|
||
|
||
In `src/server.ts`, find where the other admin routes are registered (e.g. `app.register(receivedInvoicesRoutes, { prefix: "/api/admin/received-invoices" })`) and add, alongside them:
|
||
|
||
```ts
|
||
import aiRoutes from "./routes/admin/ai";
|
||
// ...
|
||
await app.register(aiRoutes, { prefix: "/api/admin/ai" });
|
||
```
|
||
|
||
(Match the existing import style — some route files are imported at top, some inline. Follow whatever `server.ts` already does for `receivedInvoicesRoutes`.)
|
||
|
||
- [ ] **Step 6: Run the tests + gate**
|
||
|
||
Run: `npx vitest run` (all pass, including the new AI HTTP tests) and `npx tsc -b --noEmit` (exit 0).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/schemas/ai.schema.ts src/routes/admin/ai.ts src/server.ts src/__tests__/ai.test.ts
|
||
git commit -m "feat(ai): chat / extract-invoices / usage / budget routes"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Frontend — dashboard chat widget
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/admin/lib/queries/ai.ts`
|
||
- Create: `src/admin/components/dashboard/DashAssistant.tsx`
|
||
- Modify: `src/admin/pages/Dashboard.tsx`
|
||
|
||
Frontend-only; gate on `tsc -b` + `build`.
|
||
|
||
- [ ] **Step 1: Query helpers**
|
||
|
||
Create `src/admin/lib/queries/ai.ts`:
|
||
|
||
```ts
|
||
import { queryOptions } from "@tanstack/react-query";
|
||
import { jsonQuery } from "../apiAdapter";
|
||
|
||
export interface AiUsage {
|
||
configured: boolean;
|
||
month_spend_usd: number;
|
||
budget_usd: number;
|
||
remaining_usd: number;
|
||
}
|
||
|
||
export interface ExtractedInvoice {
|
||
supplier_name: string;
|
||
invoice_number: string | null;
|
||
amount: number;
|
||
currency: string;
|
||
vat_rate: number;
|
||
issue_date: string | null;
|
||
due_date: string | null;
|
||
description: string | null;
|
||
}
|
||
|
||
export const aiUsageOptions = () =>
|
||
queryOptions({
|
||
queryKey: ["ai", "usage"],
|
||
queryFn: () => jsonQuery<AiUsage>("/api/admin/ai/usage"),
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: The widget**
|
||
|
||
Create `src/admin/components/dashboard/DashAssistant.tsx`. It holds the conversation in component state, sends chat turns, and on file-attach calls `/extract-invoices` then renders editable review cards that save via the existing `/received-invoices` endpoint.
|
||
|
||
```tsx
|
||
import { useState, useRef } from "react";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import Box from "@mui/material/Box";
|
||
import Typography from "@mui/material/Typography";
|
||
import { Card, Button, TextField } from "../../ui";
|
||
import apiFetch from "../../utils/api";
|
||
import { useAlert } from "../../context/AlertContext";
|
||
import { aiUsageOptions, type ExtractedInvoice } from "../../lib/queries/ai";
|
||
|
||
interface ChatTurn {
|
||
role: "user" | "assistant";
|
||
content: string;
|
||
}
|
||
interface ReviewInvoice extends ExtractedInvoice {
|
||
file_name: string;
|
||
file: File;
|
||
}
|
||
|
||
export default function DashAssistant() {
|
||
const alert = useAlert();
|
||
const qc = useQueryClient();
|
||
const { data: usage } = useQuery(aiUsageOptions());
|
||
const [turns, setTurns] = useState<ChatTurn[]>([]);
|
||
const [input, setInput] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
const [review, setReview] = useState<ReviewInvoice[]>([]);
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
|
||
// AI is hidden entirely when the backend isn't configured.
|
||
if (usage && usage.configured === false) return null;
|
||
|
||
const sendChat = async () => {
|
||
const text = input.trim();
|
||
if (!text || busy) return;
|
||
const next = [...turns, { role: "user" as const, content: text }];
|
||
setTurns(next);
|
||
setInput("");
|
||
setBusy(true);
|
||
try {
|
||
const res = await apiFetch("/api/admin/ai/chat", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ messages: next }),
|
||
});
|
||
const body = await res.json();
|
||
if (!res.ok) throw new Error(body?.error || "Chyba AI");
|
||
setTurns((t) => [...t, { role: "assistant", content: body.data.reply }]);
|
||
qc.invalidateQueries({ queryKey: ["ai", "usage"] });
|
||
} catch (e) {
|
||
alert.error(e instanceof Error ? e.message : "Chyba AI");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const onFiles = async (files: FileList | null) => {
|
||
if (!files || files.length === 0 || busy) return;
|
||
setBusy(true);
|
||
try {
|
||
const fd = new FormData();
|
||
Array.from(files).forEach((f) => fd.append("files", f));
|
||
const res = await apiFetch("/api/admin/ai/extract-invoices", {
|
||
method: "POST",
|
||
body: fd,
|
||
});
|
||
const body = await res.json();
|
||
if (!res.ok) throw new Error(body?.error || "Chyba čtení faktur");
|
||
const arr = Array.from(files);
|
||
const reviews: ReviewInvoice[] = (
|
||
body.data.invoices as Array<{
|
||
file_name: string;
|
||
fields?: ExtractedInvoice;
|
||
error?: string;
|
||
}>
|
||
)
|
||
.map((r, i) =>
|
||
r.fields
|
||
? { ...r.fields, file_name: r.file_name, file: arr[i] }
|
||
: null,
|
||
)
|
||
.filter((x): x is ReviewInvoice => x !== null);
|
||
setReview((prev) => [...prev, ...reviews]);
|
||
setTurns((t) => [
|
||
...t,
|
||
{
|
||
role: "assistant",
|
||
content: `Načetl jsem ${reviews.length} faktur k potvrzení.`,
|
||
},
|
||
]);
|
||
qc.invalidateQueries({ queryKey: ["ai", "usage"] });
|
||
} catch (e) {
|
||
alert.error(e instanceof Error ? e.message : "Chyba čtení faktur");
|
||
} finally {
|
||
setBusy(false);
|
||
if (fileRef.current) fileRef.current.value = "";
|
||
}
|
||
};
|
||
|
||
const saveInvoice = async (inv: ReviewInvoice, idx: number) => {
|
||
setBusy(true);
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append("files", inv.file, inv.file_name);
|
||
fd.append(
|
||
"invoices",
|
||
JSON.stringify([
|
||
{
|
||
supplier_name: inv.supplier_name,
|
||
invoice_number: inv.invoice_number ?? undefined,
|
||
description: inv.description ?? undefined,
|
||
amount: inv.amount,
|
||
currency: inv.currency,
|
||
vat_rate: inv.vat_rate,
|
||
issue_date: inv.issue_date ?? undefined,
|
||
due_date: inv.due_date ?? undefined,
|
||
},
|
||
]),
|
||
);
|
||
const res = await apiFetch("/api/admin/received-invoices", {
|
||
method: "POST",
|
||
body: fd,
|
||
});
|
||
const body = await res.json();
|
||
if (!res.ok) throw new Error(body?.error || "Uložení selhalo");
|
||
alert.success("Faktura uložena");
|
||
setReview((r) => r.filter((_, i) => i !== idx));
|
||
qc.invalidateQueries({ queryKey: ["received-invoices"] });
|
||
} catch (e) {
|
||
alert.error(e instanceof Error ? e.message : "Uložení selhalo");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const patch = (idx: number, field: keyof ReviewInvoice, value: string) =>
|
||
setReview((r) =>
|
||
r.map((inv, i) => (i === idx ? { ...inv, [field]: value } : inv)),
|
||
);
|
||
|
||
return (
|
||
<Card sx={{ mb: 3 }}>
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
mb: 1,
|
||
}}
|
||
>
|
||
<Typography
|
||
variant="subtitle2"
|
||
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||
>
|
||
AI asistent
|
||
</Typography>
|
||
{usage && (
|
||
<Typography variant="caption" color="text.secondary">
|
||
Rozpočet: ${usage.month_spend_usd.toFixed(2)} / $
|
||
{usage.budget_usd.toFixed(2)}
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 1,
|
||
maxHeight: 280,
|
||
overflowY: "auto",
|
||
mb: 1,
|
||
}}
|
||
>
|
||
{turns.map((t, i) => (
|
||
<Box
|
||
key={i}
|
||
sx={{
|
||
alignSelf: t.role === "user" ? "flex-end" : "flex-start",
|
||
maxWidth: "85%",
|
||
px: 1.5,
|
||
py: 1,
|
||
borderRadius: 2,
|
||
bgcolor: t.role === "user" ? "primary.main" : "action.hover",
|
||
color: t.role === "user" ? "common.white" : "text.primary",
|
||
}}
|
||
>
|
||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
|
||
{t.content}
|
||
</Typography>
|
||
</Box>
|
||
))}
|
||
{review.map((inv, idx) => (
|
||
<Card key={`rev-${idx}`} variant="outlined" sx={{ p: 1.5 }}>
|
||
<Typography variant="caption" color="text.secondary">
|
||
{inv.file_name}
|
||
</Typography>
|
||
<Box
|
||
sx={{
|
||
display: "grid",
|
||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||
gap: 1,
|
||
mt: 0.5,
|
||
}}
|
||
>
|
||
<TextField
|
||
label="Dodavatel"
|
||
value={inv.supplier_name}
|
||
onChange={(e) => patch(idx, "supplier_name", e.target.value)}
|
||
/>
|
||
<TextField
|
||
label="Číslo faktury"
|
||
value={inv.invoice_number ?? ""}
|
||
onChange={(e) => patch(idx, "invoice_number", e.target.value)}
|
||
/>
|
||
<TextField
|
||
label="Částka"
|
||
value={String(inv.amount)}
|
||
onChange={(e) => patch(idx, "amount", e.target.value)}
|
||
/>
|
||
<TextField
|
||
label="Měna"
|
||
value={inv.currency}
|
||
onChange={(e) => patch(idx, "currency", e.target.value)}
|
||
/>
|
||
<TextField
|
||
label="Sazba DPH %"
|
||
value={String(inv.vat_rate)}
|
||
onChange={(e) => patch(idx, "vat_rate", e.target.value)}
|
||
/>
|
||
<TextField
|
||
label="Datum vystavení"
|
||
value={inv.issue_date ?? ""}
|
||
onChange={(e) => patch(idx, "issue_date", e.target.value)}
|
||
/>
|
||
</Box>
|
||
<Box sx={{ display: "flex", gap: 1, mt: 1 }}>
|
||
<Button onClick={() => saveInvoice(inv, idx)} disabled={busy}>
|
||
Uložit
|
||
</Button>
|
||
<Button
|
||
variant="outlined"
|
||
color="inherit"
|
||
onClick={() => setReview((r) => r.filter((_, i) => i !== idx))}
|
||
disabled={busy}
|
||
>
|
||
Zahodit
|
||
</Button>
|
||
</Box>
|
||
</Card>
|
||
))}
|
||
</Box>
|
||
|
||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept="application/pdf,image/*"
|
||
multiple
|
||
hidden
|
||
onChange={(e) => onFiles(e.target.files)}
|
||
/>
|
||
<Button
|
||
variant="outlined"
|
||
color="inherit"
|
||
onClick={() => fileRef.current?.click()}
|
||
disabled={busy}
|
||
>
|
||
Přiložit fakturu
|
||
</Button>
|
||
<TextField
|
||
fullWidth
|
||
placeholder="Napište zprávu…"
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
sendChat();
|
||
}
|
||
}}
|
||
/>
|
||
<Button onClick={sendChat} disabled={busy || !input.trim()}>
|
||
Odeslat
|
||
</Button>
|
||
</Box>
|
||
</Card>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Render it on the dashboard**
|
||
|
||
In `src/admin/pages/Dashboard.tsx`: import the widget, and render it directly under the welcome `<Typography variant="h4">` (around line 232), gated on admin / `ai.use`.
|
||
|
||
```tsx
|
||
import DashAssistant from "../components/dashboard/DashAssistant";
|
||
```
|
||
|
||
and right after the welcome heading block:
|
||
|
||
```tsx
|
||
{
|
||
hasPermission("ai.use") && <DashAssistant />;
|
||
}
|
||
```
|
||
|
||
(`hasPermission` is already destructured from `useAuth()` in this file; the admin role returns true for any permission, so admins see it. The widget self-hides if the backend reports `configured: false`.)
|
||
|
||
- [ ] **Step 4: Gate + Chrome check**
|
||
|
||
```bash
|
||
npx tsc -b --noEmit
|
||
npm run build
|
||
```
|
||
|
||
Then in Chrome on `/dashboard` (dev server already running — do not start it; `ANTHROPIC_API_KEY` must be set in dev `.env` for live testing): the "AI asistent" card shows under the welcome text for an admin; type a message → reply appears; attach an invoice PDF → review card appears pre-filled → edit → "Uložit" creates a Přijatá faktura; the budget indicator updates. (If no API key is set in dev, the widget is hidden — that's expected.)
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/admin/lib/queries/ai.ts src/admin/components/dashboard/DashAssistant.tsx src/admin/pages/Dashboard.tsx
|
||
git commit -m "feat(ai): dashboard chat widget with invoice import + budget indicator"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Release v2.1.0
|
||
|
||
**Files:**
|
||
|
||
- Modify: `package.json`
|
||
|
||
- [ ] **Step 1: Bump version** to `"version": "2.1.0"`.
|
||
|
||
- [ ] **Step 2: Full gate**
|
||
|
||
```bash
|
||
npx tsc -b --noEmit
|
||
npx vitest run
|
||
npm run build
|
||
```
|
||
|
||
- [ ] **Step 3: Merge to master + commit + tag**
|
||
|
||
```bash
|
||
git checkout master
|
||
git merge --ff-only feat/ai-assistant-phase1
|
||
git add package.json
|
||
git commit -m "chore(release): v2.1.0 — AI assistant (chat + invoice import + budget)"
|
||
git tag -a v2.1.0 -m "v2.1.0 — AI assistant Phase 1"
|
||
```
|
||
|
||
(If implementation happened directly on master, skip checkout/merge.)
|
||
|
||
- [ ] **Step 4: Push to Gitea**
|
||
|
||
```bash
|
||
git push origin master
|
||
git push origin v2.1.0
|
||
```
|
||
|
||
- [ ] **Step 5: Set the production API key (one-time, before deploy)**
|
||
|
||
On the prod server, add `ANTHROPIC_API_KEY=<key>` to `/var/www/app-ts/.env`. Without it the assistant stays hidden (no errors). The key comes from the Anthropic Console.
|
||
|
||
- [ ] **Step 6: Build tarball + deploy (REQUIRES explicit user confirmation)**
|
||
|
||
```bash
|
||
tar -czf app-ts-2.1.0.tar.gz dist dist-client prisma package.json package-lock.json scripts
|
||
scp app-ts-2.1.0.tar.gz boha_admin@192.168.50.100:/tmp/
|
||
ssh boha_admin@192.168.50.100 'set -e; cd /var/www/app-ts && rm -rf dist dist-client prisma scripts package.json package-lock.json && tar -xzf /tmp/app-ts-2.1.0.tar.gz && npm install --omit=dev && npx prisma migrate deploy && pm2 restart app-ts --update-env'
|
||
```
|
||
|
||
**This release DOES ship a migration** (`add_ai_assistant`), so `prisma migrate deploy` applies the `ai_usage` table + `ai_monthly_budget_usd` column + `ai.use` permission on prod.
|
||
|
||
- [ ] **Step 7: Health check**
|
||
|
||
```bash
|
||
ssh boha_admin@192.168.50.100 'curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3001/' # 200
|
||
ssh boha_admin@192.168.50.100 'curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3001/api/admin/session' # 401
|
||
```
|
||
|
||
Run the second curl a couple of seconds after restart (avoid the cold-start `000`). Confirm pm2 shows `app-ts` v2.1.0 online.
|
||
|
||
---
|
||
|
||
## Notes for the implementer
|
||
|
||
- **The Anthropic call is never made in tests.** All AI tests exercise the cost/budget/permission/not-configured logic, which short-circuits before the SDK. Do not add a test that calls `chat()`/`extractInvoice()` against the real API.
|
||
- **Reuse, don't duplicate:** invoice saves go through the existing `POST /received-invoices` (month/year/vat_amount/status are filled server-side). The AI only extracts.
|
||
- **Budget is a guard, not a hard ceiling mid-call:** the check is before each call; a single call can slightly exceed the budget. That's acceptable (calls are cheap).
|
||
- **Do not start the dev server** — the user runs it. The migration apply needs it stopped (controller coordinates).
|
||
- **Do not deploy to production without explicit confirmation** (Task 5, Steps 6–7).
|
||
- **SDK API shapes** (`messages.create`, `output_config.format`, `document` content block, `res.usage`) are from the Anthropic TypeScript SDK — if a binding differs from what's shown, check the installed `@anthropic-ai/sdk` types rather than guessing.
|