Der Katalog bleibt gemeinsam - ein Set existiert einmal, unabhaengig davon, wer es hat. Besitz und Wunschlisten gehoeren dagegen je einem Sammler. Damit beantwortet "hat er das schon?" jetzt die Frage "hat *er* das schon?": ein Set, das nur die Schwester besitzt, faellt fuer den Bruder wieder unter "fehlt noch" und darf auf seine Wunschliste. Wichtig fuer den Alltag: Solange es nur einen Sammler gibt, taucht die Unterscheidung nirgends in der Oberflaeche auf. Kein Umschalter, keine Abfrage beim Hinzufuegen, keine Badges - die App bleibt exakt so einfach wie vorher. Erst der zweite Sammler schaltet all das frei. Die Besitzlogik liegt weiterhin an genau einer Stelle (src/lib/ownership.ts) und hat nur eine Dimension dazubekommen. Das automatische "erfuellt" auf Wunschlisten fragt jetzt ebenfalls je Liste: ein Wunsch auf Max' Liste ist erfuellt, wenn Max das Set bekommt - nicht wenn Mia es hat. Listen ohne Sammler verhalten sich wie bisher. Die Migration legt einen Sammler "Sammlung" an und ordnet ihm alles Vorhandene zu; umbenennen laesst er sich unter Verwaltung -> Sammler. Der Seed uebernimmt ihn statt einen dritten anzulegen. Refs Phase 3 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
227 lines
7.2 KiB
TypeScript
227 lines
7.2 KiB
TypeScript
import { z } from 'zod';
|
|
import { parseEuroInput } from './money';
|
|
|
|
/**
|
|
* Server-side validation for every form in the app.
|
|
*
|
|
* All input arrives as FormData, i.e. as strings, so the schemas do the
|
|
* coercion as well: empty fields become null, prices become cents, dates
|
|
* become Date objects.
|
|
*/
|
|
|
|
/** Empty text fields must not be stored as empty strings. */
|
|
export const optionalText = (max = 500) =>
|
|
z
|
|
.string()
|
|
.trim()
|
|
.max(max, `Höchstens ${max} Zeichen.`)
|
|
.nullish()
|
|
.transform((value) => (value && value.length > 0 ? value : null));
|
|
|
|
export const optionalUrl = z
|
|
.string()
|
|
.trim()
|
|
.nullish()
|
|
.transform((value) => (value && value.length > 0 ? value : null))
|
|
.refine(
|
|
(value) => value === null || /^https?:\/\/\S+$/i.test(value),
|
|
'Bitte eine vollständige Adresse mit http:// oder https:// angeben.',
|
|
);
|
|
|
|
/** A select that may be left on "keine Auswahl". */
|
|
export const optionalId = z
|
|
.string()
|
|
.trim()
|
|
.nullish()
|
|
.transform((value) => (value && value.length > 0 && value !== 'none' ? value : null));
|
|
|
|
export const optionalInt = (min: number, max: number) =>
|
|
z
|
|
.string()
|
|
.trim()
|
|
.nullish()
|
|
.transform((value) => (value && value.length > 0 ? Number(value) : null))
|
|
.refine(
|
|
(value) => value === null || (Number.isInteger(value) && value >= min && value <= max),
|
|
`Bitte eine ganze Zahl zwischen ${min} und ${max} angeben.`,
|
|
);
|
|
|
|
/** Money arrives as "12,99" or "12.99" and is stored as cents. */
|
|
export const optionalPriceCents = z
|
|
.string()
|
|
.nullish()
|
|
.transform((value) => parseEuroInput(value ?? null))
|
|
.refine((value) => value === null || value <= 100_000_00, 'Der Betrag ist unrealistisch hoch.');
|
|
|
|
export const optionalDate = z
|
|
.string()
|
|
.trim()
|
|
.nullish()
|
|
.transform((value) => (value && value.length > 0 ? new Date(`${value}T12:00:00`) : null))
|
|
.refine(
|
|
(value) => value === null || !Number.isNaN(value.getTime()),
|
|
'Bitte ein gültiges Datum angeben.',
|
|
);
|
|
|
|
export const checkbox = z
|
|
.union([z.literal('on'), z.literal('true'), z.literal('false'), z.literal('')])
|
|
.nullish()
|
|
.transform((value) => value === 'on' || value === 'true');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Master data
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const manufacturerSchema = z.object({
|
|
name: z.string().trim().min(1, 'Bitte einen Namen angeben.').max(80),
|
|
website: optionalUrl,
|
|
});
|
|
|
|
export const themeSchema = z.object({
|
|
manufacturerId: z.string().trim().min(1, 'Bitte einen Hersteller wählen.'),
|
|
name: z.string().trim().min(1, 'Bitte einen Namen angeben.').max(80),
|
|
parentThemeId: optionalId,
|
|
});
|
|
|
|
export const locationSchema = z.object({
|
|
name: z.string().trim().min(1, 'Bitte einen Namen angeben.').max(80),
|
|
parentLocationId: optionalId,
|
|
});
|
|
|
|
export const collectorColorEnum = z.enum([
|
|
'blue',
|
|
'green',
|
|
'amber',
|
|
'rose',
|
|
'violet',
|
|
'slate',
|
|
]);
|
|
|
|
export const collectorSchema = z.object({
|
|
name: z.string().trim().min(1, 'Bitte einen Namen angeben.').max(60),
|
|
color: collectorColorEnum.default('blue'),
|
|
birthday: optionalDate,
|
|
notes: optionalText(500),
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Catalog
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const currentYear = new Date().getFullYear();
|
|
|
|
export const setSchema = z.object({
|
|
manufacturerId: z.string().trim().min(1, 'Bitte einen Hersteller wählen.'),
|
|
themeId: optionalId,
|
|
setNumber: z.string().trim().min(1, 'Bitte die Setnummer angeben.').max(40),
|
|
name: z.string().trim().min(1, 'Bitte einen Namen angeben.').max(160),
|
|
year: optionalInt(1930, currentYear + 5),
|
|
pieceCount: optionalInt(0, 100_000),
|
|
minifigCount: optionalInt(0, 500),
|
|
ean: optionalText(20),
|
|
ageRange: optionalText(20),
|
|
imageUrl: optionalUrl,
|
|
retailPriceCents: optionalPriceCents,
|
|
notes: optionalText(2000),
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Collection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const collectionStatusEnum = z.enum([
|
|
'owned',
|
|
'built',
|
|
'disassembled',
|
|
'lent_out',
|
|
'sold',
|
|
]);
|
|
|
|
export const completenessEnum = z.enum(['complete', 'missing_parts', 'unknown']);
|
|
|
|
export const conditionEnum = z.enum(['new_sealed', 'like_new', 'good', 'used', 'damaged']);
|
|
|
|
export const collectionItemSchema = z.object({
|
|
setId: z.string().trim().min(1),
|
|
collectorId: optionalId,
|
|
status: collectionStatusEnum.default('owned'),
|
|
completeness: completenessEnum.default('unknown'),
|
|
condition: z
|
|
.string()
|
|
.trim()
|
|
.nullish()
|
|
.transform((value) => (value && value.length > 0 && value !== 'none' ? value : null))
|
|
.refine(
|
|
(value) => value === null || conditionEnum.safeParse(value).success,
|
|
'Unbekannter Zustand.',
|
|
),
|
|
locationId: optionalId,
|
|
acquiredAt: optionalDate,
|
|
acquiredPriceCents: optionalPriceCents,
|
|
acquiredFrom: optionalText(120),
|
|
giftedBy: optionalText(120),
|
|
lentOutTo: optionalText(120),
|
|
notes: optionalText(2000),
|
|
});
|
|
|
|
/**
|
|
* A missing part needs at least something identifying: a part number or a
|
|
* note. An entry with neither says nothing and would only clutter the list.
|
|
*/
|
|
export const missingPartSchema = z
|
|
.object({
|
|
partNumber: optionalText(40),
|
|
colorName: optionalText(40),
|
|
quantity: z.coerce.number().int().min(1).max(999).default(1),
|
|
notes: optionalText(300),
|
|
})
|
|
.refine(
|
|
(value) => value.partNumber !== null || value.notes !== null,
|
|
'Bitte gib mindestens die Teilenummer oder eine Notiz an.',
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wishlists
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const occasionEnum = z.enum(['christmas', 'birthday', 'easter', 'other']);
|
|
|
|
export const wishlistSchema = z.object({
|
|
title: z.string().trim().min(1, 'Bitte einen Titel angeben.').max(80),
|
|
collectorId: optionalId,
|
|
occasion: occasionEnum.default('other'),
|
|
eventDate: optionalDate,
|
|
note: optionalText(1000),
|
|
isPublic: checkbox,
|
|
surpriseMode: checkbox,
|
|
});
|
|
|
|
export const wishlistItemSchema = z.object({
|
|
priority: z.coerce.number().int().min(1).max(3).default(2),
|
|
note: optionalText(500),
|
|
});
|
|
|
|
/** Bulk add from the "fehlende Sets" selection. */
|
|
export const addSetsToWishlistSchema = z.object({
|
|
wishlistId: z.string().trim().min(1, 'Bitte eine Wunschliste wählen.'),
|
|
setIds: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens ein Set auswählen.'),
|
|
priority: z.coerce.number().int().min(1).max(3).default(2),
|
|
});
|
|
|
|
export const reservationSchema = z.object({
|
|
itemId: z.string().trim().min(1),
|
|
name: z
|
|
.string()
|
|
.trim()
|
|
.min(2, 'Bitte trag deinen Namen ein, damit klar ist, wer das Geschenk übernimmt.')
|
|
.max(60),
|
|
});
|
|
|
|
export type ManufacturerInput = z.infer<typeof manufacturerSchema>;
|
|
export type ThemeInput = z.infer<typeof themeSchema>;
|
|
export type LocationInput = z.infer<typeof locationSchema>;
|
|
export type SetInput = z.infer<typeof setSchema>;
|
|
export type CollectionItemInput = z.infer<typeof collectionItemSchema>;
|
|
export type WishlistInput = z.infer<typeof wishlistSchema>;
|
|
export type CollectorInput = z.infer<typeof collectorSchema>;
|