Files
craftvia/src/lib/lotse/chat.ts
T
msolarczekandClaude Opus 5 06d320908f L17 Pakete: Stufen Basis/Profi, Lotse-Chat-Plätze und Kontingent
- Datenmodell: Tenant.tier (Default PROFI), lotseChatSeats, lotseChatHardLimit;
  Tabelle lotse_chat_seats (RLS, TENANT_MODELS, pii-fields), Index für die
  Monatszählung der Chat-Nachrichten (Migration 20260921100000_pakete)
- src/lib/plans.ts: Stufenregeln, 150 Chats je Platz, Mehrverbrauch in 100er-Paketen
- src/server/plan.ts: effektive Freischaltung = Stufe UND TenantModule, genutzt von
  requireModule, assertModuleEnabled, API, Sync (Offline-Op → rejected mit Klartext),
  Navigation, isLotseEnabled und planningAccess (Planung nur in Profi)
- Lotse-Chat: Platzprüfung (no_seat), Testphase ohne Platz, Kontingent mit
  hartem Limit (quota_exhausted); Platzvergabe durch den Mandanten-Admin
- Betreiber: Stufe/Plätze/hartes Limit im Mandantendetail mit Bestätigung
  und Plattform-Audit, Verbrauch laufender Monat/Vormonat, Stufe als Badge
- Demo-Seed: demo = Profi mit 3 Plätzen, demo2 = Basis
- Tests: test-pakete-{rules,gates,seats}

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 10:13:27 +02:00

191 lines
6.5 KiB
TypeScript

import { z } from "zod";
import { NOTE_KINDS, TIME_TYPES } from "@/lib/sync/ops";
import { LOTSE_TEXT_FIELDS, LOTSE_TEXT_MAX } from "@/lib/lotse/content";
/**
* Lotse chat for technicians (lane L16) — client-safe contract: proposal kinds and their payload
* schemas, structured message parts, action state of the chat server actions.
*
* Principle: the Lotse never executes anything. Proposal tools create a `LotseActionProposal`
* whose payload is validated with these schemas; only the owner's confirmation runs the existing
* services (services/lotse/chat/confirm.ts).
*/
export const PROPOSAL_TTL_MINUTES = 30;
export const CHAT_TEXT_MAX = 4_000;
export const PROPOSAL_KINDS = ["transition_work_order", "book_time", "record_material", "add_note", "suggest_report_fields"] as const;
export type ProposalKind = (typeof PROPOSAL_KINDS)[number];
/** Execution order of „Alle bestätigen": bookings first, the status change (completion) last. */
export const PROPOSAL_ORDER: Record<ProposalKind, number> = {
book_time: 1,
record_material: 2,
add_note: 3,
suggest_report_fields: 4,
transition_work_order: 5,
};
export const TRANSITION_ACTIONS = ["accept", "start_travel", "start_work", "pause", "resume", "complete"] as const;
export type TransitionAction = (typeof TRANSITION_ACTIONS)[number];
/** Time types the chat may book (breaks are not booked via chat). */
export const CHAT_TIME_TYPES = ["work", "travel", "return_travel", "material_procurement"] as const satisfies readonly (typeof TIME_TYPES)[number][];
const id = z.string().min(1).max(64);
const number = z.string().min(1).max(64);
const dateKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const clock = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
export const transitionPayloadSchema = z.object({
workOrderId: id,
number,
action: z.enum(TRANSITION_ACTIONS),
/** L12 auto switch: number of the order whose running clock is paused on confirmation */
switchFrom: number.nullish(),
});
export const bookTimePayloadSchema = z
.object({
workOrderId: id,
number,
type: z.enum(CHAT_TIME_TYPES),
date: dateKey,
/** local time HH:MM (tenant time zone) — either from/to or durationMinutes */
from: clock.nullish(),
to: clock.nullish(),
durationMinutes: z.number().int().min(1).max(16 * 60).nullish(),
/** L12: mandatory reason of a manual entry */
reason: z.string().trim().min(3).max(500),
note: z.string().trim().max(2000).nullish(),
})
.refine((v) => (v.from && v.to) || v.durationMinutes, { message: "from/to or durationMinutes required", path: ["durationMinutes"] });
export const recordMaterialPayloadSchema = z.object({
workOrderId: id,
number,
materialPlanId: id.nullish(),
name: z.string().trim().min(1).max(200),
articleNumber: z.string().trim().max(100).nullish(),
quantity: z.number().min(0).max(1_000_000),
unit: z.string().trim().min(1).max(20),
usageStatus: z.enum(["fully_used", "partially_used", "not_used", "additional"]),
deviationReason: z.string().trim().max(2000).nullish(),
});
export const addNotePayloadSchema = z.object({
workOrderId: id,
number,
kind: z.enum(NOTE_KINDS),
text: z.string().trim().min(1).max(10_000),
});
export const reportFieldsPayloadSchema = z.object({
workOrderId: id,
number,
reportId: id,
reportType: z.enum(["daily", "completion"]),
fields: z
.array(z.object({ field: z.enum(LOTSE_TEXT_FIELDS), text: z.string().trim().min(1).max(LOTSE_TEXT_MAX) }))
.min(1)
.max(LOTSE_TEXT_FIELDS.length),
});
export const PROPOSAL_SCHEMAS = {
transition_work_order: transitionPayloadSchema,
book_time: bookTimePayloadSchema,
record_material: recordMaterialPayloadSchema,
add_note: addNotePayloadSchema,
suggest_report_fields: reportFieldsPayloadSchema,
} as const;
export type ProposalPayload<K extends ProposalKind> = z.output<(typeof PROPOSAL_SCHEMAS)[K]>;
/** Fields a technician may change before confirming („Bearbeiten"); ids/number stay fixed. */
export const EDITABLE_FIELDS: Record<ProposalKind, readonly string[]> = {
transition_work_order: [],
book_time: ["type", "date", "from", "to", "durationMinutes", "reason", "note"],
record_material: ["name", "quantity", "unit", "deviationReason"],
add_note: ["kind", "text"],
suggest_report_fields: ["fields"],
};
export const PROPOSAL_STATUSES = ["proposed", "confirmed", "discarded", "expired", "failed"] as const;
export type ProposalStatus = (typeof PROPOSAL_STATUSES)[number];
/** Chip = quick answer; `value` is sent as the next user message. */
export type ChatChip = { label: string; value: string };
/** Jump target (e.g. missing mandatory photo → /m/orders/<id>/photos). `labelKey` = messages lotse.chat.links.* */
export type ChatLink = { labelKey: string; label?: string; href: string };
export type ChatMessageContent = {
chips?: ChatChip[];
proposalIds?: string[];
links?: ChatLink[];
/** assistant text exactly as produced by the model (with placeholders) — history for the next turn */
modelText?: string;
/** user text as sent to the model (minimised) */
sentText?: string;
/** i18n key for server-generated messages (lotse.chat.system.*) */
noticeKey?: string;
noticeValues?: Record<string, string | number>;
/** rounds of the tool loop (transparency) */
rounds?: number;
};
export type ChatProposalView = {
id: string;
kind: ProposalKind;
status: ProposalStatus;
payload: Record<string, unknown>;
expiresAt: string;
errorCode: string | null;
result: Record<string, unknown> | null;
workOrderId: string | null;
};
export type ChatMessageView = {
id: string;
role: "user" | "assistant" | "tool";
text: string;
createdAt: string;
content: ChatMessageContent;
proposals: ChatProposalView[];
};
export type ChatView = {
conversationId: string | null;
workOrder: { id: string; number: string; title: string } | null;
messages: ChatMessageView[];
};
export const LOTSE_CHAT_ERROR_CODES = [
"generic",
"not_found",
"forbidden",
"disabled",
"chat_disabled",
"not_configured",
"provider_failed",
"budget_exceeded",
"invalid",
"conflict",
"expired",
"already_decided",
"tampered",
"blocked",
"offline",
// L17 Pakete
"not_in_plan",
"no_seat",
"quota_exhausted",
] as const;
export type LotseChatErrorCode = (typeof LOTSE_CHAT_ERROR_CODES)[number];
export type LotseChatActionState =
| { status: "idle" }
| { status: "ok"; at: number; view: ChatView }
| { status: "error"; code: LotseChatErrorCode; at: number; view?: ChatView };
export const LOTSE_CHAT_IDLE: LotseChatActionState = { status: "idle" };