import { z } from "zod/v4"; import { isPublicUrl } from "@/lib/validations/notifications"; export const moodLogCredentialsSchema = z.object({ url: z .string() .url() .max(500) // SSRF guard: stored URL must point at a public host. The sync // worker fetches from this URL with the user's apiKey in the // Authorization header, so a stored RFC1918 / link-local target // would let any user pull cloud-metadata or local-network data // back through their account. .refine((u) => isPublicUrl(u), { message: "URL must point at a public host (no RFC1918 / link-local)", }), apiKey: z.string().min(1).max(200), }); export const moodLogWebhookPayloadSchema = z.object({ event: z.enum(["mood.created", "mood.updated", "mood.deleted"]), timestamp: z.string().datetime(), entry: z.object({ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), time: z.string().datetime(), mood: z.enum(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]), score: z.number().int().min(1).max(5), tags: z.array(z.string()).max(50).optional(), loggedVia: z.enum(["WEB", "TELEGRAM", "DAYLIO"]).optional(), }), }); export const moodLogSyncResponseSchema = z.object({ version: z.string(), entries: z.array( z.object({ date: z.string(), time: z.string(), mood: z.string(), score: z.number(), tags: z.array(z.string()).optional(), loggedVia: z.string().optional(), }), ), }); export type MoodLogCredentials = z.infer; export type MoodLogWebhookPayload = z.infer; export type MoodLogSyncResponse = z.infer; // --- CRUD schemas for mood entries --- export const moodLevelEnum = z.enum([ "SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG", ]); export const moodSourceEnum = z.enum([ "MANUAL", "MOODLOG", "WEB", "TELEGRAM", "DAYLIO", ]); const MOOD_SCORE_MAP: Record = { SUPER_GUT: 5, GUT: 4, OKAY: 3, SCHLECHT: 2, LAUSIG: 1, }; export function getScoreForMood(mood: string): number { return MOOD_SCORE_MAP[mood] ?? 3; } // v1.8.5 — structured-tag keys picked from the catalog (`mood_tags.key`). // Additive alongside the flat free-text `tags`: an entry can carry both. // Bounded so a single create can't fan out an unbounded link set. const structuredTagKeys = z.array(z.string().max(60)).max(30); export const createMoodEntrySchema = z.object({ mood: moodLevelEnum, tags: z.array(z.string().max(50)).max(20).optional(), // v1.8.5 — structured-tag keys from the taxonomy. Server resolves each // key to a `MoodTag` row and writes the `MoodEntryTagLink` join; // unknown keys are dropped silently (the catalog is the source of // truth, a stale client can't mint a tag). tagKeys: structuredTagKeys.optional(), // v1.4.30 H-5 — first-class free-text note. Replaces the // `tags: ["note:"]` workaround. Capped at 500 chars so the // Coach evidence shelf renders cleanly without truncating chips. note: z.string().max(500).optional(), moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), source: moodSourceEnum.optional().default("MANUAL"), }); export const updateMoodEntrySchema = z.object({ mood: moodLevelEnum.optional(), tags: z.array(z.string().max(50)).max(20).nullable().optional(), // v1.8.5 — full replacement of the structured-tag set when present. // `null` clears every link; omit to leave links untouched. tagKeys: structuredTagKeys.nullable().optional(), note: z.string().max(500).nullable().optional(), moodLoggedAt: z.iso .datetime({ offset: true }) .transform((s) => new Date(s)) .optional(), }); export const listMoodEntriesSchema = z.object({ mood: moodLevelEnum.optional(), from: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) .optional(), to: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) .optional(), limit: z.coerce.number().int().min(1).max(500).optional().default(100), offset: z.coerce.number().int().min(0).optional().default(0), sortBy: z .enum(["date", "mood", "score", "moodLoggedAt", "source"]) .optional() .default("moodLoggedAt"), sortDir: z.enum(["asc", "desc"]).optional().default("desc"), }); export type CreateMoodEntryInput = z.infer; export type UpdateMoodEntryInput = z.infer; export type ListMoodEntriesInput = z.infer;