Files
TREK/shared/src/collection/collection.schema.ts
T

429 lines
19 KiB
TypeScript

import { idSchema } from '../common/primitives.schema';
import {
placeCategorySchema,
placeImageUrlSchema,
placeRatingVoteSchema,
placeWebsiteSchema,
} from '../place/place.schema';
import { tagSchema } from '../tag/tag.schema';
import { z } from 'zod';
export const COLLECTION_STATUSES = ['idea', 'want', 'visited'] as const;
export const collectionStatusSchema = z.enum(COLLECTION_STATUSES).catch('idea').default('idea');
export type CollectionStatus = (typeof COLLECTION_STATUSES)[number];
/** Per-member permission on a shared list. viewer = read + copy-to-trip only;
* editor (default) = add + edit places; admin = full incl. delete. The owner
* is always full and is not a member row. */
export const COLLECTION_ROLES = ['viewer', 'editor', 'admin'] as const;
export const collectionRoleSchema = z.enum(COLLECTION_ROLES).catch('editor').default('editor');
export type CollectionRole = (typeof COLLECTION_ROLES)[number];
/** A user-added link on a list or a saved place (stored as a JSON array). */
export const collectionLinkSchema = z.object({
label: z.string().max(120).optional(),
// http/https only — blocks javascript:/data: hrefs and forces an absolute link.
url: z
.string()
.trim()
.max(2000)
.regex(/^https?:\/\/.+/i),
});
export type CollectionLink = z.infer<typeof collectionLinkSchema>;
export const collectionLinksSchema = z.array(collectionLinkSchema).max(30);
/** A custom label defined per-collection (distinct from the instance-wide tags).
* Members group and filter a list's places by these. */
export const collectionLabelSchema = z.object({
id: z.number(),
collection_id: z.number(),
name: z.string(),
color: z.string().nullable().optional(),
sort_order: z.number().optional(),
});
export type CollectionLabel = z.infer<typeof collectionLabelSchema>;
/** A saved place — assignmentPlace minus itinerary, plus status + provenance. */
export const collectionPlaceSchema = z.object({
id: z.number(),
collection_id: z.number(),
owner_id: z.number().optional(),
saved_by: z.number().nullable().optional(),
name: z.string(),
description: z.string().nullable().optional(),
lat: z.number().nullable().optional(),
lng: z.number().nullable().optional(),
address: z.string().nullable().optional(),
category_id: z.number().nullable().optional(),
price: z.number().nullable().optional(),
currency: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
// Deliberately open on the way out, pinned on the way in (see the save/update
// request schemas below): rows written before the request-side check existed
// still have to round-trip.
image_url: z.string().nullable().optional(),
google_place_id: z.string().nullable().optional(),
google_ftid: z.string().nullable().optional(),
osm_id: z.string().nullable().optional(),
website: z.string().nullable().optional(),
phone: z.string().nullable().optional(),
status: collectionStatusSchema,
source_trip_id: z.number().nullable().optional(),
source_place_id: z.number().nullable().optional(),
sort_order: z.number().optional(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
links: collectionLinksSchema.optional(),
category: placeCategorySchema.optional(),
tags: z.array(tagSchema.partial()).optional(),
/** Ids of the per-collection labels assigned to this place. */
label_ids: z.array(z.number()).optional(),
// Collaborative ratings (#1435): every member's vote + the displayed aggregate.
ratings: z.array(placeRatingVoteSchema).optional(),
rating_avg: z.number().nullable().optional(),
rating_count: z.number().optional(),
});
export type CollectionPlace = z.infer<typeof collectionPlaceSchema>;
/** Member of a shared list (mirrors vacay person rows). */
export const collectionMemberSchema = z.object({
user_id: z.number(),
username: z.string(),
email: z.string().optional(),
avatar: z.string().nullable().optional(),
status: z.enum(['pending', 'accepted']),
role: collectionRoleSchema.optional(),
is_owner: z.boolean().optional(),
});
export type CollectionMember = z.infer<typeof collectionMemberSchema>;
/** A list, with computed counts + membership for the current viewer. */
export const collectionSchema = z.object({
id: z.number(),
owner_id: z.number(),
name: z.string(),
description: z.string().nullable().optional(),
color: z.string().nullable().optional(),
icon: z.string().nullable().optional(),
cover_image: z.string().nullable().optional(),
links: collectionLinksSchema.optional(),
sort_order: z.number().optional(),
place_count: z.number().optional(),
is_owner: z.boolean().optional(),
members: z.array(collectionMemberSchema).optional(),
labels: z.array(collectionLabelSchema).optional(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
});
export type Collection = z.infer<typeof collectionSchema>;
// ── Requests ──────────────────────────────────────────────────────────────
export const collectionCreateRequestSchema = z.object({
name: z.string().min(1).max(120),
description: z.string().max(2000).nullable().optional(),
color: z
.string()
.regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/)
.optional(),
icon: z.string().max(40).optional(),
cover_image: z.string().max(500).nullable().optional(),
links: collectionLinksSchema.optional(),
});
export type CollectionCreateRequest = z.infer<typeof collectionCreateRequestSchema>;
export const collectionUpdateRequestSchema = collectionCreateRequestSchema.partial().extend({
sort_order: z.number().optional(),
});
export type CollectionUpdateRequest = z.infer<typeof collectionUpdateRequestSchema>;
/** Reorder the caller's lists — every visible collection id in the desired order.
* Plain z.number() (no .min/.int) mirrors the legacy hand-rolled check the DTO
* ratchet replaced: any array of numbers, empty included. */
export const collectionReorderRequestSchema = z.object({
orderedIds: z.array(z.number()),
});
export type CollectionReorderRequest = z.infer<typeof collectionReorderRequestSchema>;
/** Save a place into a list from a raw maps/manual payload (or carrying provenance). */
export const collectionSavePlaceRequestSchema = z.object({
collection_id: z.number(),
source_place_id: z.number().nullable().optional(),
source_trip_id: z.number().nullable().optional(),
name: z.string().min(1),
description: z.string().nullable().optional(),
lat: z.number().nullable().optional(),
lng: z.number().nullable().optional(),
address: z.string().nullable().optional(),
category_id: z.number().nullable().optional(),
price: z.number().nullable().optional(),
currency: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
image_url: placeImageUrlSchema.nullable().optional(),
google_place_id: z.string().nullable().optional(),
google_ftid: z.string().nullable().optional(),
osm_id: z.string().nullable().optional(),
website: placeWebsiteSchema.nullable().optional(),
phone: z.string().nullable().optional(),
status: collectionStatusSchema.optional(),
links: collectionLinksSchema.optional(),
tag_ids: z.array(z.number()).optional(),
force: z.boolean().optional(), // "add anyway" over a dedup match
});
export type CollectionSavePlaceRequest = z.infer<typeof collectionSavePlaceRequestSchema>;
/** DEDICATED DTO for POST /places/from-trip — the server reads the place, so no place payload. */
export const collectionSaveFromTripRequestSchema = z.object({
collection_id: z.number(),
source_trip_id: z.number(),
source_place_id: z.number(),
force: z.boolean().optional(),
});
export type CollectionSaveFromTripRequest = z.infer<typeof collectionSaveFromTripRequestSchema>;
/** Bulk: copy several selected trip places into a list at once. */
export const collectionSaveFromTripManyRequestSchema = z.object({
collection_id: z.number(),
source_trip_id: z.number(),
source_place_ids: z.array(z.number()).min(1).max(1000),
force: z.boolean().optional(),
});
export type CollectionSaveFromTripManyRequest = z.infer<typeof collectionSaveFromTripManyRequestSchema>;
export const collectionPlaceUpdateRequestSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
// Editable coordinates so a place added by GPS can be corrected later (#1435).
lat: z.number().nullable().optional(),
lng: z.number().nullable().optional(),
// Free-text address, correctable after saving (#1870): the add dialog always
// offered it, the edit form did not. Deliberately uncapped, exactly like
// collectionSavePlaceRequestSchema.address, so save and update stay in step.
address: z.string().nullable().optional(),
// .removeDefault() strips the inner .default('idea') so an ABSENT status parses to
// undefined (left unchanged) instead of being injected as 'idea' (see #1437). The
// .catch('idea') guard against invalid values is preserved.
status: collectionStatusSchema.removeDefault().optional(),
category_id: z.number().nullable().optional(),
collection_id: z.number().optional(), // move to another list
links: collectionLinksSchema.optional(),
tag_ids: z.array(z.number()).optional(),
// Replace the place's per-collection label assignments (omit to leave unchanged).
label_ids: z.array(z.number()).optional(),
// Custom thumbnail (#1136): null clears it (falls back to the auto-fetched photo).
image_url: placeImageUrlSchema.nullable().optional(),
});
export type CollectionPlaceUpdateRequest = z.infer<typeof collectionPlaceUpdateRequestSchema>;
export const collectionSetStatusRequestSchema = z.object({ status: collectionStatusSchema });
export type CollectionSetStatusRequest = z.infer<typeof collectionSetStatusRequestSchema>;
/** Set one status on several saved places at once — the place dialog's "mark
* visited everywhere it is saved" (#1469). Ids are collection_places ids. */
export const collectionSetStatusManyRequestSchema = z.object({
ids: z.array(z.number()).min(1).max(1000),
status: collectionStatusSchema,
});
export type CollectionSetStatusManyRequest = z.infer<typeof collectionSetStatusManyRequestSchema>;
/** Same, but naming the places by their TRIP ids: the server resolves each one to
* the lists it is saved in. Drives the planner's bulk "mark as visited" (#1469). */
export const collectionSetStatusFromTripRequestSchema = z.object({
trip_id: z.number(),
place_ids: z.array(z.number()).min(1).max(1000),
status: collectionStatusSchema,
});
export type CollectionSetStatusFromTripRequest = z.infer<typeof collectionSetStatusFromTripRequestSchema>;
export const collectionSetStatusManyResponseSchema = z.object({
/** Saved places whose status actually changed. */
updated: z.number(),
/** Trip places that were found in at least one list (from-trip form only). */
places: z.number().optional(),
});
export type CollectionSetStatusManyResponse = z.infer<typeof collectionSetStatusManyResponseSchema>;
/** Bulk-delete saved places. Plain z.number() (no .min/.int) mirrors the legacy
* hand-rolled check the DTO ratchet replaced (same shape as placeBulkDeleteRequestSchema). */
export const collectionDeleteManyRequestSchema = z.object({
ids: z.array(z.number()),
});
export type CollectionDeleteManyRequest = z.infer<typeof collectionDeleteManyRequestSchema>;
/** Copy one or many saved places INTO a trip (dedup precheck on server). */
export const collectionCopyToTripRequestSchema = z.object({
trip_id: z.number(),
place_ids: z.array(z.number()).min(1),
force: z.boolean().optional(),
});
export type CollectionCopyToTripRequest = z.infer<typeof collectionCopyToTripRequestSchema>;
/** What a copy into a trip did: how many went in, the ones the trip had, and each new trip place by its source. */
export const collectionCopyToTripResponseSchema = z.object({
copied: z.number(),
skipped: z.array(z.object({ id: z.number(), name: z.string() })),
created: z.array(z.object({ id: z.number(), source_id: z.number() })),
});
export type CollectionCopyToTripResponse = z.infer<typeof collectionCopyToTripResponseSchema>;
/** Saved places and labels asked for by their ids, across every list the caller can see (get_collection_items). */
export const collectionItemsRequestSchema = z.object({
placeIds: z.array(idSchema).max(50).optional(),
labelIds: z.array(idSchema).max(20).optional(),
});
export type CollectionItemsRequest = z.infer<typeof collectionItemsRequestSchema>;
// Fusion invitations. user_id is NUMERIC ONLY — the UI always sends an id from availableUsers.
export const collectionInviteRequestSchema = z.object({
collection_id: z.number(),
user_id: z.number(),
role: collectionRoleSchema.optional(),
});
export type CollectionInviteRequest = z.infer<typeof collectionInviteRequestSchema>;
export const collectionInviteActionRequestSchema = z.object({ collection_id: z.number() });
export type CollectionInviteActionRequest = z.infer<typeof collectionInviteActionRequestSchema>;
export const collectionInviteCancelRequestSchema = z.object({
collection_id: z.number(),
user_id: z.number(),
});
export type CollectionInviteCancelRequest = z.infer<typeof collectionInviteCancelRequestSchema>;
/** Owner removes an ALREADY-ACCEPTED member (kick). */
export const collectionRemoveMemberRequestSchema = z.object({
collection_id: z.number(),
user_id: z.number(),
});
export type CollectionRemoveMemberRequest = z.infer<typeof collectionRemoveMemberRequestSchema>;
/** Owner changes an accepted member's permission role. */
export const collectionSetMemberRoleRequestSchema = z.object({
collection_id: z.number(),
user_id: z.number(),
role: collectionRoleSchema,
});
export type CollectionSetMemberRoleRequest = z.infer<typeof collectionSetMemberRoleRequestSchema>;
// ── Labels ──────────────────────────────────────────────────────────────────
const labelColorSchema = z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
/** Create a custom label in a list. */
export const collectionLabelCreateRequestSchema = z.object({
collection_id: z.number(),
name: z.string().trim().min(1).max(60),
color: labelColorSchema.optional(),
});
export type CollectionLabelCreateRequest = z.infer<typeof collectionLabelCreateRequestSchema>;
/** Rename / recolor a label (all fields optional). */
export const collectionLabelUpdateRequestSchema = z.object({
name: z.string().trim().min(1).max(60).optional(),
color: labelColorSchema.optional(),
sort_order: z.number().optional(),
});
export type CollectionLabelUpdateRequest = z.infer<typeof collectionLabelUpdateRequestSchema>;
/** Bulk add or remove one/several labels across many selected places. */
export const collectionLabelAssignRequestSchema = z.object({
label_ids: z.array(z.number()).min(1).max(50),
place_ids: z.array(z.number()).min(1).max(1000),
});
export type CollectionLabelAssignRequest = z.infer<typeof collectionLabelAssignRequestSchema>;
// ── Responses ─────────────────────────────────────────────────────────────
export const collectionListResponseSchema = z.object({
collections: z.array(collectionSchema),
// `from` is DERIVED from collections.owner_id JOIN users (sendInvite is owner-only, so the
// inviter is always the list owner — collection_members has no invited_by column).
incomingInvites: z.array(
z.object({
collection_id: z.number(),
name: z.string(),
from: z.object({ id: z.number(), username: z.string() }),
}),
),
});
export type CollectionListResponse = z.infer<typeof collectionListResponseSchema>;
export const collectionDetailResponseSchema = z.object({
collection: collectionSchema,
places: z.array(collectionPlaceSchema),
});
export type CollectionDetailResponse = z.infer<typeof collectionDetailResponseSchema>;
/**
* The asked-for places and labels with the lists they sit in: each list's
* size, colour, labels and people (names and roles, no addresses), and
* whether the caller may delete places from it (its owner or an admin). An id
* the caller cannot see is left out, never refused.
*/
const collectionItemsListSchema = collectionSchema
.pick({ id: true, name: true, color: true, place_count: true, is_owner: true, labels: true })
.extend({
can_delete: z.boolean(),
members: z.array(
collectionMemberSchema.pick({ user_id: true, username: true, status: true, role: true, is_owner: true }),
),
});
export const collectionItemsResponseSchema = z.object({
lists: z.array(collectionItemsListSchema),
places: z.array(collectionPlaceSchema),
labels: z.array(collectionLabelSchema),
});
export type CollectionItemsResponse = z.infer<typeof collectionItemsResponseSchema>;
/** Dedup outcome envelope reused by save + copy (matches placeService dedup UX). */
export const collectionSaveResultSchema = z.object({
place: collectionPlaceSchema.optional(),
duplicate: z.boolean().optional(),
duplicateOf: z.object({ id: z.number(), name: z.string() }).nullable().optional(),
});
export type CollectionSaveResult = z.infer<typeof collectionSaveResultSchema>;
/** Library-wide "is this place already saved anywhere I can see?" lookup (inspector indicator). */
export const collectionMembershipSchema = z.object({
saved: z.boolean(),
lists: z.array(
z.object({
collection_id: z.number(),
name: z.string(),
/** The list's colour, for its dot; older servers leave it out. */
color: z.string().nullable().optional(),
place_id: z.number(),
/** Per-list status, so the picker can show and change it without a second round trip (#1469). */
status: collectionStatusSchema,
/** False for a list the viewer may read but not edit — its status pill stays read-only. */
can_edit: z.boolean().default(true),
}),
),
});
export type CollectionMembership = z.infer<typeof collectionMembershipSchema>;
/** One trip place as offered by the import preview. `already_in_list` is the SAME dedup
* verdict the bulk import applies (name, or coordinates within tolerance), resolved on the
* server so the dialog can never disagree with what the write path then does. `scheduled`
* is false for a place no day holds — the ones a trip left behind, which is exactly what
* the import is for, so the dialog pre-selects them. */
export const collectionImportablePlaceSchema = z.object({
place_id: z.number(),
name: z.string(),
address: z.string().nullable(),
lat: z.number().nullable(),
lng: z.number().nullable(),
category_id: z.number().nullable(),
image_url: z.string().nullable(),
already_in_list: z.boolean(),
scheduled: z.boolean(),
day_number: z.number().nullable(),
date: z.string().nullable(),
});
export type CollectionImportablePlace = z.infer<typeof collectionImportablePlaceSchema>;
export const collectionImportablesResponseSchema = z.object({
places: z.array(collectionImportablePlaceSchema),
});
export type CollectionImportablesResponse = z.infer<typeof collectionImportablesResponseSchema>;