Files
TREK/shared/src/packing/packing.schema.ts
T
Claude 639c40f8de fix(packing): a bag weighs what everyone put in it, not just what you can see
A bag's weight was summed in the browser over the item list the viewer is
allowed to see. Personal items belonging to other members are filtered out of
that list by design (#858), so their weight vanished with them: two people
looking at the same shared bag saw two different totals, and neither saw the
real one.

That number is then measured against `weight_limit_grams`, an absolute airline
limit. A per-viewer subtotal there is not merely incomplete but wrong — a
shared bag could sit over its limit and warn nobody.

The sum now happens in SQL over every row and ships as `total_weight_grams` on
the bag, with `unassigned_weight_grams` alongside so the "no bag" pile and the
grand total follow the same rule; a screen mixing true totals with per-viewer
ones would be worse than either. Only integers cross the wire: a member learns
that a bag is heavier than the items they can see, never a name, category,
quantity or owner. That disclosure is deliberate and bounded, and it is what
the issue asks for.

The item lists stay viewer-scoped, so #1767 ("someone shared it with you, but
they are the one bringing it") is unchanged — it just no longer decides what a
bag weighs.

Totals also had to stay live, because the reported repro is two people looking
at the same screen. A private item's event reaches only its owner, which is the
very rule that made the totals wrong, so item writes now also ping the room
with a content-free `packing:bag-totals` and everyone re-reads the numbers.

All three surfaces — the bag sidebar, the bag modal and the mobile sheet — read
the server figure and fall back to the local sum only for a bag cached offline
before this change.

Closes #2191

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3
2026-09-02 16:21:50 +02:00

193 lines
8.1 KiB
TypeScript

import { z } from 'zod';
/**
* Packing API contract — single source of truth for the
* /api/trips/:tripId/packing endpoints (items, bags, templates, assignees).
*
* Trip-scoped: every endpoint verifies trip access (404 "Trip not found") and
* mutations additionally check the 'packing_edit' permission (403 "No
* permission"). The legacy route (server/src/routes/packing.ts) wraps
* services/packingService.ts; rows are DB-shaped and kept as open records here.
* Mutations broadcast over WebSocket using the forwarded X-Socket-Id.
*/
const open = z.record(z.string(), z.unknown());
/**
* Packing item entity as returned by the packing endpoints
* (server/src/services/packingService.ts -> SELECT * FROM packing_items).
* `checked` is the raw SQLite INTEGER (0/1). Columns match the packing_items
* table (see server DB): weight_grams/bag_id are nullable, quantity defaults 1.
*/
export const packingItemSchema = z.object({
id: z.number(),
trip_id: z.number(),
name: z.string(),
checked: z.number(),
category: z.string().nullable().optional(),
sort_order: z.number(),
weight_grams: z.number().nullable().optional(),
bag_id: z.number().nullable().optional(),
quantity: z.number().optional(),
// Three-tier sharing (#858). is_private is the raw SQLite INTEGER (0/1):
// 0 = Common (group pool, visible to all), 1 = restricted. owner_id is the
// "bringer". A restricted item with no recipients is Personal; with recipients
// it's Shared-with-those-people. owner_username/recipients/contributors are
// attached by the listing for display ("brought by X" / "taken care of by X").
is_private: z.number().optional(),
owner_id: z.number().nullable().optional(),
owner_username: z.string().nullable().optional(),
recipients: z.array(z.object({ user_id: z.number(), username: z.string() })).optional(),
contributors: z.array(z.object({ user_id: z.number(), username: z.string(), status: z.string() })).optional(),
created_at: z.string().optional(),
// Optimistic-concurrency token for offline conflict detection (#1135). Added
// by migration 98; older rows backfill from created_at.
updated_at: z.string().nullable().optional(),
});
export type PackingItem = z.infer<typeof packingItemSchema>;
/**
* Packing bag member embedded on a bag (server packingService -> listBags).
* `avatar` is the resolved avatar URL.
*/
export const packingBagMemberSchema = z.object({
user_id: z.number(),
username: z.string(),
avatar: z.string().nullable().optional(),
});
export type PackingBagMember = z.infer<typeof packingBagMemberSchema>;
/**
* Packing bag entity (server packingService -> listBags). Columns of the
* packing_bags table plus the embedded `members` array (and the optional
* `assigned_username` join present on updateBag).
*/
export const packingBagSchema = z.object({
id: z.number(),
trip_id: z.number(),
name: z.string(),
color: z.string(),
weight_limit_grams: z.number().nullable().optional(),
sort_order: z.number(),
user_id: z.number().nullable().optional(),
assigned_username: z.string().nullable().optional(),
created_at: z.string().optional(),
members: z.array(packingBagMemberSchema).optional(),
/**
* What the bag really weighs, summed server-side over every member's items
* (#2191). The client used to add this up from the item list it was allowed
* to see, which silently dropped other members' private items and left the
* figure measured against an absolute weight limit wrong for everyone but
* its owner. Optional: an offline-cached bag from before #2191 has no such
* field, and the surfaces fall back to their local sum when it is absent.
*/
total_weight_grams: z.number().optional(),
});
export type PackingBag = z.infer<typeof packingBagSchema>;
// Three-tier sharing (#858): Common (group pool), Personal (private), or Shared
// with specific people.
export const packingVisibilitySchema = z.enum(['common', 'personal', 'shared']);
export type PackingVisibility = z.infer<typeof packingVisibilitySchema>;
export const packingCreateItemRequestSchema = z.object({
name: z.string().min(1),
category: z.string().optional(),
// The legacy route accepted both boolean and 0/1 for checked — both stay valid.
checked: z.union([z.boolean(), z.number().int().min(0).max(1)]).optional(),
// Same shapes as the update schema below (#2154) — the create route used to
// strip these three silently, forcing a second write to set them.
weight_grams: z.number().nullable().optional(),
bag_id: z.number().nullable().optional(),
quantity: z.number().optional(),
// Mark the new item private to its creator (#858, legacy flag).
is_private: z.boolean().optional(),
// Three-tier sharing (#858): which list the item belongs to, and — for 'shared' —
// the people it covers ("taken care of by you").
visibility: packingVisibilitySchema.optional(),
recipient_ids: z.array(z.number()).optional(),
});
export type PackingCreateItemRequest = z.infer<typeof packingCreateItemRequestSchema>;
// Re-set an item's sharing tier + the people a 'shared' item covers (#858).
export const packingSetSharingRequestSchema = z.object({
visibility: packingVisibilitySchema,
recipient_ids: z.array(z.number()).optional(),
});
export type PackingSetSharingRequest = z.infer<typeof packingSetSharingRequestSchema>;
export const packingUpdateItemRequestSchema = z.object({
name: z.string().optional(),
// The legacy route accepted both boolean and 0/1 for checked — both stay valid.
checked: z.union([z.boolean(), z.number().int().min(0).max(1)]).optional(),
category: z.string().optional(),
weight_grams: z.number().nullable().optional(),
bag_id: z.number().nullable().optional(),
quantity: z.number().optional(),
// Toggle the item's privacy (#858).
is_private: z.boolean().optional(),
});
export type PackingUpdateItemRequest = z.infer<typeof packingUpdateItemRequestSchema>;
export const packingImportRequestSchema = z.object({
items: z.array(open),
});
export type PackingImportRequest = z.infer<typeof packingImportRequestSchema>;
export const packingReorderRequestSchema = z.object({
orderedIds: z.array(z.number()),
});
export type PackingReorderRequest = z.infer<typeof packingReorderRequestSchema>;
export const packingCreateBagRequestSchema = z.object({
name: z.string().min(1),
color: z.string().optional(),
// Same shape as the update schema below (#2154) — POST used to strip it.
weight_limit_grams: z.number().nullable().optional(),
});
export type PackingCreateBagRequest = z.infer<typeof packingCreateBagRequestSchema>;
export const packingUpdateBagRequestSchema = z.object({
name: z.string().optional(),
color: z.string().optional(),
weight_limit_grams: z.number().nullable().optional(),
user_id: z.number().nullable().optional(),
});
export type PackingUpdateBagRequest = z.infer<typeof packingUpdateBagRequestSchema>;
export const packingBagMembersRequestSchema = z.object({
user_ids: z.array(z.number()),
});
export type PackingBagMembersRequest = z.infer<typeof packingBagMembersRequestSchema>;
export const packingSaveTemplateRequestSchema = z.object({
name: z.string().min(1),
});
export type PackingSaveTemplateRequest = z.infer<typeof packingSaveTemplateRequestSchema>;
// The whole body is optional: the legacy route accepted a body-less POST
// (visibility then defaults to 'common' in the controller).
export const packingApplyTemplateRequestSchema = z
.object({
visibility: z.enum(['common', 'personal']).optional(),
})
.optional();
export type PackingApplyTemplateRequest = z.infer<typeof packingApplyTemplateRequestSchema>;
export const packingTemplateSummarySchema = z.object({
id: z.number(),
name: z.string(),
item_count: z.number(),
});
export type PackingTemplateSummary = z.infer<typeof packingTemplateSummarySchema>;
export const packingTemplatesResponseSchema = z.object({
templates: z.array(packingTemplateSummarySchema),
});
export type PackingTemplatesResponse = z.infer<typeof packingTemplatesResponseSchema>;
export const packingCategoryAssigneesRequestSchema = z.object({
user_ids: z.array(z.number()),
});
export type PackingCategoryAssigneesRequest = z.infer<typeof packingCategoryAssigneesRequestSchema>;