Files
TREK/shared/src/admin/admin.schema.ts
T
Maurice 7acf7f5655 feat(admin): a daily limit on Google API calls
Admin > Settings > Google options gets a daily limit with today's count beside it. Every call through the Places and Routes clients is counted per UTC day; once the limit is reached the Google key reads as absent until the next day, so search, details and photos fall back to OpenStreetMap the way a keyless install does, and the admin sees that the day is used up. (#1582)
2026-10-01 12:15:38 +02:00

179 lines
8.1 KiB
TypeScript

import { z } from 'zod';
/**
* Admin API contract for /api/admin (admin-only).
*
* These schemas exist to satisfy the body-contract ratchet without changing
* behavior, so they are deliberately permissive: wherever AdminService already
* emits a bespoke 400 of its own ('Invalid role', 'Name is required',
* 'Username, email and password are required'), the field stays optional and
* loosely typed so that error survives byte-identically. Every object schema is
* non-strict — several clients and tests send extra keys and expect success.
*/
export const adminUserCreateRequestSchema = z.object({
username: z.string().optional(),
email: z.string().optional(),
password: z.string().optional(),
// Deliberately z.string(), not z.enum: the service's own 'Invalid role' 400
// is the pinned contract for a bad role.
role: z.string().optional(),
});
export type AdminUserCreateRequest = z.infer<typeof adminUserCreateRequestSchema>;
export const adminUserUpdateRequestSchema = z.object({
username: z.string().optional(),
email: z.string().optional(),
password: z.string().optional(),
role: z.string().optional(),
});
export type AdminUserUpdateRequest = z.infer<typeof adminUserUpdateRequestSchema>;
// Values stay z.unknown(): PermissionsService.savePermissions reports unknown
// levels through its `skipped` list (a 200 response field the admin UI shows),
// so narrowing to z.string() would turn that into a 400.
export const adminPermissionsRequestSchema = z.object({
permissions: z.record(z.string(), z.unknown()),
});
export type AdminPermissionsRequest = z.infer<typeof adminPermissionsRequestSchema>;
export const adminInviteCreateRequestSchema = z.object({
// string | number: the service parses with parseInt(String(x)), and the
// admin UI sends numbers while older clients sent strings.
max_uses: z.union([z.number(), z.string()]).optional(),
expires_in_days: z.union([z.number(), z.string()]).optional(),
role: z.enum(['user', 'admin']).optional(),
// Optional trip binding (#1402): a user who registers via the link is
// auto-added to this trip. Nullable/absent = a plain registration invite.
trip_id: z.union([z.number(), z.string()]).nullable().optional(),
});
export type AdminInviteCreateRequest = z.infer<typeof adminInviteCreateRequestSchema>;
export const adminFeatureToggleRequestSchema = z.object({
enabled: z.boolean(),
});
export type AdminFeatureToggleRequest = z.infer<typeof adminFeatureToggleRequestSchema>;
/**
* PUT /api/admin/google-quota (#1582): the most Google API calls TREK may make
* in one UTC day. Null or 0 removes the ceiling.
*/
export const googleQuotaUpdateRequestSchema = z.object({
daily_limit: z.number().int().min(0).max(10_000_000).nullable(),
});
export type GoogleQuotaUpdateRequest = z.infer<typeof googleQuotaUpdateRequestSchema>;
/** The ceiling as stored, today's count, and whether Google is paused until tomorrow. */
export interface GoogleQuotaStatus {
daily_limit: number | null;
used_today: number;
exhausted: boolean;
}
// Shared by all six packing-template create/update routes. `name` is optional so
// the service's 'Name is required' / 'Category name is required' / 'Item name is
// required' 400s stay the contract, and so the update routes keep treating a
// blank name as a no-op.
export const adminTemplateNameRequestSchema = z.object({
name: z.string().optional(),
});
export type AdminTemplateNameRequest = z.infer<typeof adminTemplateNameRequestSchema>;
export const adminOidcUpdateRequestSchema = z.object({
issuer: z.string().optional(),
client_id: z.string().optional(),
client_secret: z.string().optional(),
display_name: z.string().optional(),
discovery_url: z.string().optional(),
});
export type AdminOidcUpdateRequest = z.infer<typeof adminOidcUpdateRequestSchema>;
// `config` must stay free-form: each addon stores its own keys, and the AI addon
// round-trips an apiKey mask sentinel that a narrower shape would strip.
export const adminAddonUpdateRequestSchema = z.object({
enabled: z.boolean().optional(),
config: z.record(z.string(), z.unknown()).optional(),
});
export type AdminAddonUpdateRequest = z.infer<typeof adminAddonUpdateRequestSchema>;
/**
* Whether the AI Parsing model is handed a photo, stored as `vision` on that
* addon's config. `auto` asks a local Ollama server what the model can do and
* means no for a cloud provider; `on` and `off` are the admin's word.
*/
export const LLM_VISION_MODES = ['auto', 'on', 'off'] as const;
export type LlmVision = (typeof LLM_VISION_MODES)[number];
/**
* The photos handed to a model that reads images. HEIC is not here: no provider
* TREK talks to reads it, and a browser picking from the camera roll hands over
* a JPEG anyway.
*/
export const LLM_PHOTO_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'] as const;
/** GET /api/llm/capabilities: what the caller's AI model can be handed. */
export const llmCapabilitiesResponseSchema = z.object({ images: z.boolean() });
export type LlmCapabilitiesResponse = z.infer<typeof llmCapabilitiesResponseSchema>;
/** A stored or submitted value as a mode; anything else, a missing value included, is `auto`. */
export function asLlmVision(value: unknown): LlmVision {
return (LLM_VISION_MODES as readonly unknown[]).includes(value) ? (value as LlmVision) : 'auto';
}
// Fully partial — the client PUTs one computed key at a time.
export const adminCollabFeaturesRequestSchema = z.object({
chat: z.boolean().optional(),
notes: z.boolean().optional(),
links: z.boolean().optional(),
polls: z.boolean().optional(),
whatsnext: z.boolean().optional(),
});
export type AdminCollabFeaturesRequest = z.infer<typeof adminCollabFeaturesRequestSchema>;
// event type -> channel id -> enabled. Anything narrower would silently drop
// plugin-contributed channels, since the client echoes back the whole matrix.
export const adminNotificationPreferencesRequestSchema = z.record(z.string(), z.record(z.string(), z.boolean()));
export type AdminNotificationPreferencesRequest = z.infer<typeof adminNotificationPreferencesRequestSchema>;
// Heterogeneous values, and `null` is meaningful — SettingsService treats it as
// "reset to the built-in default". z.record also rejects arrays and null bodies,
// preserving the route's object-only guard.
export const adminDefaultUserSettingsRequestSchema = z.record(z.string(), z.unknown());
export type AdminDefaultUserSettingsRequest = z.infer<typeof adminDefaultUserSettingsRequestSchema>;
// Dev-only test sender: every field optional (callers POST {}), and `inApp` is a
// boolean OR the in-app action descriptor the dev panel sends.
export const adminTestNotificationRequestSchema = z.object({
event: z.string().optional(),
scope: z.string().optional(),
targetId: z.number().optional(),
params: z.record(z.string(), z.unknown()).optional(),
inApp: z.union([z.boolean(), z.record(z.string(), z.unknown())]).optional(),
});
export type AdminTestNotificationRequest = z.infer<typeof adminTestNotificationRequestSchema>;
// Which backend answers /api/transit (#1699). Transitous stays the default and
// the fallback: it is free and keyless, so an install that never opens this
// switch — or that picks Google without a key — keeps costing nothing.
export const TRANSIT_PROVIDERS = ['transitous', 'google'] as const;
export type TransitProvider = (typeof TRANSIT_PROVIDERS)[number];
export const adminTransitProviderRequestSchema = z.object({
provider: z.enum(TRANSIT_PROVIDERS),
});
export type AdminTransitProviderRequest = z.infer<typeof adminTransitProviderRequestSchema>;
/**
* Which of the three credential slots a third-party key resolved out of —
* operator env, the instance-wide row, or the caller's own. The server's
* ApiKeySource aliases this rather than restating it: the admin transit
* response puts the value on the wire, and two copies of the union would be
* exactly the hand-mirrored contract the project forbids.
*
* `null` on the wire means no key resolved at all.
*/
export const API_KEY_SOURCES = ['operator-env', 'instance', 'user-row'] as const;
export type ApiKeySource = (typeof API_KEY_SOURCES)[number];
/** The nullable form the admin transit-provider response carries. */
export type TransitKeySource = ApiKeySource | null;