mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
429 lines
19 KiB
TypeScript
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>;
|