feat(packing): three-tier sharing — personal, shared-with-people, common pool (#858)

Rework the private-packing flag into a full sharing model. Every item is now
Common (the group pool — where all existing items live, so nothing breaks),
Personal (private to its owner) or Shared with specific people (it shows up on
those travelers' own lists, marked "by <bringer>"). is_private discriminates
restricted from common; a new packing_item_recipients table holds who a shared
item covers, and packing_item_contributors records "I can bring that too"
pledges on Common items.

The panel gains a Gemeinsam / Meine Liste view switch, each item a sharing
control (owner sets the tier + the people it covers), and Common items can be
co-brought or cloned onto your personal list. Visibility is enforced server-side
in listItems and the WebSocket broadcasts are scoped to exactly who can see an
item across every tier transition. All 22 locales stay in parity.
This commit is contained in:
Maurice
2026-06-30 16:15:24 +02:00
committed by Maurice
parent 04f2ec72c6
commit 743b724cbc
42 changed files with 1292 additions and 96 deletions
+5 -1
View File
@@ -24,7 +24,7 @@ import {
type ReservationCreateRequest, type ReservationUpdateRequest,
type AccommodationCreateRequest, type AccommodationUpdateRequest,
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
type PackingCreateItemRequest, type PackingUpdateItemRequest,
type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest,
type TodoCreateItemRequest, type TodoUpdateItemRequest,
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
type PlaceBulkDeleteRequest,
@@ -408,6 +408,10 @@ export const packingApi = {
update: (tripId: number | string, id: number, data: PackingUpdateItemRequest) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
setSharing: (tripId: number | string, id: number, data: PackingSetSharingRequest) => apiClient.put(`/trips/${tripId}/packing/${id}/sharing`, data).then(r => r.data),
clone: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/packing/${id}/clone`).then(r => r.data),
addContributor: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/packing/${id}/contributors`).then(r => r.data),
removeContributor: (tripId: number | string, id: number, userId: number) => apiClient.delete(`/trips/${tripId}/packing/${id}/contributors/${userId}`).then(r => r.data),
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
listTemplates: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/templates`).then(r => r.data),
@@ -1496,4 +1496,35 @@ describe('PackingListPanel', () => {
await waitFor(() => expect(putBody).toMatchObject({ name: 'Tent' }));
expect(posted).toBe(false);
});
// ── Three-tier sharing (#858) ──────────────────────────────────────────────
it('FE-COMP-PACKING-080: the view switch separates the Common pool from My list', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 1 }), isAuthenticated: true });
const items = [
buildPackingItem({ name: 'Group tent', is_private: 0 }),
buildPackingItem({ name: 'My diary', is_private: 1, owner_id: 1 }),
];
render(<PackingListPanel tripId={1} items={items} />);
// Default view = Common pool → only the shared item.
expect(await screen.findByText('Group tent')).toBeInTheDocument();
expect(screen.queryByText('My diary')).not.toBeInTheDocument();
// Switch to "My list" → only the personal item.
await userEvent.click(screen.getByText('My list'));
expect(await screen.findByText('My diary')).toBeInTheDocument();
expect(screen.queryByText('Group tent')).not.toBeInTheDocument();
});
it('FE-COMP-PACKING-081: a shared-to-me item shows the "by <bringer>" badge in My list', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 1 }), isAuthenticated: true });
const items = [
buildPackingItem({ name: 'Power bank', is_private: 1, owner_id: 2, owner_username: 'Bob', recipients: [{ user_id: 1, username: 'me' }] }),
];
render(<PackingListPanel tripId={1} items={items} />);
await userEvent.click(screen.getByText('My list'));
await screen.findByText('Power bank');
// "by Bob" — taken care of by the bringer.
expect(screen.getByText('by Bob')).toBeInTheDocument();
});
});
@@ -1,7 +1,7 @@
import { usePackingList } from './usePackingListPanel'
import type { PackingListPanelProps } from './usePackingListPanel'
import { PackingHeader } from './PackingListPanelHeader'
import { PackingFilterTabs } from './PackingListPanelFilterTabs'
import { PackingViewTabs } from './PackingListPanelViewTabs'
import { PackingList } from './PackingListPanelList'
import { BagSidebar } from './PackingListPanelBagSidebar'
import { BagModal } from './PackingListPanelBagModal'
@@ -18,8 +18,8 @@ export default function PackingListPanel(props: PackingListPanelProps) {
{/* ── Header ── */}
<PackingHeader {...S} />
{/* ── Filter-Tabs ── */}
<PackingFilterTabs {...S} />
{/* ── Tabs: Gemeinsam / Meine Liste (#858) + Filter ── */}
<PackingViewTabs {...S} />
{/* ── Liste + Bags Sidebar ── */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
@@ -32,9 +32,15 @@ interface KategorieGruppeProps {
// order is global, so a within-category drag is mapped back onto the full list.
allItems: PackingItem[]
onReorder: (orderedIds: number[]) => void
// Three-tier sharing (#858) — threaded down to each item's share control.
currentUserId?: number
onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
onClone?: (id: number) => void
onJoin?: (id: number) => void
onLeave?: (id: number, userId: number) => void
}
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder }: KategorieGruppeProps) {
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
const [offen, setOffen] = useState(true)
const [dragId, setDragId] = useState<number | null>(null)
const [overId, setOverId] = useState<number | null>(null)
@@ -261,6 +267,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
<div style={{ padding: '4px 4px 6px' }}>
{items.map(item => (
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit}
tripMembers={tripMembers} currentUserId={currentUserId} onSetSharing={onSetSharing} onClone={onClone} onJoin={onJoin} onLeave={onLeave}
drag={canEdit ? {
isDragging: dragId === item.id,
isOver: overId === item.id && dragId !== null && dragId !== item.id,
@@ -1,17 +0,0 @@
import type { PackingState } from './usePackingListPanel'
export function PackingFilterTabs({ items, filter, setFilter, t }: PackingState) {
if (items.length === 0) return null
return (
<div style={{ display: 'flex', gap: 4, padding: '10px 0 0', flexShrink: 0 }}>
{[['alle', t('packing.filterAll')], ['offen', t('packing.filterOpen')], ['erledigt', t('packing.filterDone')]].map(([id, label]) => (
<button key={id} onClick={() => setFilter(id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontFamily: 'inherit', fontWeight: filter === id ? 600 : 400,
background: filter === id ? 'var(--text-primary)' : 'transparent',
color: filter === id ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>{label}</button>
))}
</div>
)
}
@@ -1,15 +1,16 @@
import { useState } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import {
CheckSquare, Square, Trash2, Plus, Pencil, Package, GripVertical, Lock, Unlock,
CheckSquare, Square, Trash2, Plus, Pencil, Package, GripVertical, UserRound, Users, HandHelping,
} from 'lucide-react'
import type { PackingItem, PackingBag } from '../../types'
import { katColor } from './packingListPanel.helpers'
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
import { QuantityInput } from './PackingListPanelQuantityInput'
import PackingShareControl from './PackingShareControl'
import type { TripMember } from './usePackingListPanel'
interface ArtikelZeileProps {
item: PackingItem
@@ -21,6 +22,13 @@ interface ArtikelZeileProps {
bags?: PackingBag[]
onCreateBag: (name: string) => Promise<PackingBag | undefined>
canEdit?: boolean
// Three-tier sharing (#858): members + handlers for the per-item share control.
tripMembers?: TripMember[]
currentUserId?: number
onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
onClone?: (id: number) => void
onJoin?: (id: number) => void
onLeave?: (id: number, userId: number) => void
// Drag-to-reorder (#969) — wired by the category group, which owns the order.
drag?: {
isDragging: boolean
@@ -32,7 +40,7 @@ interface ArtikelZeileProps {
}
}
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, drag }: ArtikelZeileProps) {
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, tripMembers = [], currentUserId, onSetSharing, onClone, onJoin, onLeave, drag }: ArtikelZeileProps) {
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
@@ -42,23 +50,19 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
const [bagInlineCreate, setBagInlineCreate] = useState(false)
const [bagInlineName, setBagInlineName] = useState('')
const { togglePackingItem, updatePackingItem, deletePackingItem } = useTripStore()
const currentUserId = useAuthStore(s => s.user?.id)
const toast = useToast()
const { t } = useTranslation()
const isPrivate = !!item.is_private
// Only the owner may toggle privacy (#858). A private item is only ever rendered
// to its owner, so the toggle on those is always the owner's; for shared items
// it shows only to the member who created them — you don't privatize others' items.
const canTogglePrivacy = canEdit && !isPlaceholder && currentUserId != null && item.owner_id === currentUserId
// Three-tier sharing display (#858).
const sharedToMe = !!item.is_private && item.owner_id != null && item.owner_id !== currentUserId
const recipients = item.recipients || []
const sharedByMe = !!item.is_private && item.owner_id === currentUserId && recipients.length > 0
const broughtBy = !item.is_private && item.owner_username ? item.owner_username : null
const contributors = item.contributors || []
const canShare = canEdit && !isPlaceholder && !!onSetSharing
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
const handleTogglePrivacy = async () => {
try { await updatePackingItem(tripId, item.id, { is_private: isPrivate ? 0 : 1 }) }
catch { toast.error(t('packing.toast.saveError')) }
}
const handleSaveName = async () => {
if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return }
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
@@ -154,10 +158,23 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
</span>
)}
{/* Private indicator (#858) — shown to the owner so they know it's hidden from others */}
{isPrivate && (
<span title={t('packing.privateHint')} style={{ display: 'flex', alignItems: 'center', color: 'var(--accent)', flexShrink: 0 }}>
<Lock size={12} />
{/* Sharing badges (#858 three-tier) */}
{!isPlaceholder && sharedToMe && (
<span title={t('packing.takenCareOf', { name: item.owner_username || '' })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 12%, transparent)', padding: '1px 7px', borderRadius: 99 }}>
<HandHelping size={10} /> {t('packing.takenCareOf', { name: item.owner_username || '' })}
</span>
)}
{!isPlaceholder && sharedByMe && (
<span title={recipients.map(r => r.username).join(', ')}
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', padding: '1px 7px', borderRadius: 99 }}>
<UserRound size={10} /> {t('packing.sharedWithCount', { count: recipients.length })}
</span>
)}
{!isPlaceholder && broughtBy && (
<span title={t('packing.broughtBy', { name: broughtBy })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '1px 4px' }}>
<Users size={10} /> {broughtBy}{contributors.length > 0 ? ` +${contributors.length}` : ''}
</span>
)}
@@ -262,7 +279,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
)}
{canEdit && (
<div className="sm:opacity-0 sm:group-hover:opacity-100" style={{ display: 'flex', gap: 2, alignItems: 'center', transition: 'opacity 0.12s', flexShrink: 0 }}>
<div style={{ display: 'flex', gap: 2, alignItems: 'center', flexShrink: 0 }}>
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowCatPicker(p => !p)}
@@ -292,13 +309,16 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
)}
</div>
{canTogglePrivacy && (
<button onClick={handleTogglePrivacy} title={isPrivate ? t('packing.makePublic') : t('packing.makePrivate')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: isPrivate ? 'var(--accent)' : 'var(--text-faint)' }}
onMouseEnter={e => { if (!isPrivate) e.currentTarget.style.color = 'var(--text-secondary)' }}
onMouseLeave={e => { if (!isPrivate) e.currentTarget.style.color = 'var(--text-faint)' }}>
{isPrivate ? <Unlock size={13} /> : <Lock size={13} />}
</button>
{canShare && onClone && onJoin && onLeave && (
<PackingShareControl
item={item}
tripMembers={tripMembers}
currentUserId={currentUserId}
onSetSharing={onSetSharing!}
onClone={onClone}
onJoin={onJoin}
onLeave={onLeave}
/>
)}
<button onClick={() => setEditing(true)} title={t('common.rename')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
@@ -7,6 +7,7 @@ export function PackingList(S: PackingState) {
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
bagTrackingEnabled, bags, handleCreateBagByName, canEdit, reorderPackingItems,
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem,
} = S
return (
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
@@ -42,6 +43,11 @@ export function PackingList(S: PackingState) {
canEdit={canEdit}
allItems={items}
onReorder={(orderedIds) => reorderPackingItems(tripId, orderedIds)}
currentUserId={currentUserId}
onSetSharing={handleSetSharing}
onClone={handleCloneItem}
onJoin={handleJoinItem}
onLeave={handleLeaveItem}
/>
))}
</div>
@@ -0,0 +1,64 @@
import { Users, UserRound } from 'lucide-react'
import type { PackingState } from './usePackingListPanel'
/**
* One tab row: the three-tier view switch (Gemeinsam / Meine Liste, #858) on the
* left, and the all/open/done filter on the right, separated by a vertical rule
* and sharing the same height. Left-aligned with the list content.
*/
export function PackingViewTabs(S: PackingState) {
const { view, setView, filter, setFilter, t, items } = S
const commonCount = items.filter(i => !i.is_private).length
const personalCount = items.filter(i => !!i.is_private).length
const pillBase: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', gap: 6, height: 30, padding: '0 12px', borderRadius: 999,
border: '1px solid', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, transition: 'all 0.12s',
}
const viewPill = (id: 'common' | 'personal', icon: React.ReactNode, label: string, count: number) => {
const active = view === id
return (
<button onClick={() => setView(id)} style={{
...pillBase,
background: active ? 'var(--text-primary)' : 'transparent',
borderColor: active ? 'var(--text-primary)' : 'var(--border-primary)',
color: active ? 'var(--bg-primary)' : 'var(--text-secondary)',
}}>
{icon}{label}
<span style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, borderRadius: 99, padding: '0 6px',
background: active ? 'var(--bg-primary)' : 'var(--bg-tertiary)',
color: active ? 'var(--text-primary)' : 'var(--text-faint)',
}}>{count}</span>
</button>
)
}
const filterPill = (id: string, label: string) => {
const active = filter === id
return (
<button key={id} onClick={() => setFilter(id)} style={{
...pillBase, gap: 0, border: '1px solid transparent', fontWeight: active ? 600 : 400,
background: active ? 'var(--text-primary)' : 'transparent',
color: active ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>{label}</button>
)
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '10px 0 0', flexShrink: 0, flexWrap: 'wrap' }}>
{viewPill('common', <Users size={14} />, t('packing.viewCommon'), commonCount)}
{viewPill('personal', <UserRound size={14} />, t('packing.viewPersonal'), personalCount)}
{items.length > 0 && (
<>
<span style={{ alignSelf: 'stretch', width: 1, background: 'var(--border-primary)', margin: '3px 4px' }} />
{filterPill('alle', t('packing.filterAll'))}
{filterPill('offen', t('packing.filterOpen'))}
{filterPill('erledigt', t('packing.filterDone'))}
</>
)}
</div>
)
}
@@ -0,0 +1,132 @@
import { useState, useRef } from 'react'
import ReactDOM from 'react-dom'
import { Users, UserRound, Share2, Check, Copy, HandHelping } from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { PackingItem } from '../../types'
import type { TripMember } from './usePackingListPanel'
interface Props {
item: PackingItem
tripMembers: TripMember[]
currentUserId?: number
onSetSharing: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
onClone: (id: number) => void
onJoin: (id: number) => void
onLeave: (id: number, userId: number) => void
}
/**
* Per-item sharing control for the three-tier packing model (#858). The owner
* (bringer) sets the tier Common / Personal / Shared with specific people via
* a dropdown; everyone else can pledge to co-bring a Common item ("I can bring
* that too") or clone it onto their own list.
*/
export default function PackingShareControl({ item, tripMembers, currentUserId, onSetSharing, onClone, onJoin, onLeave }: Props) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [pos, setPos] = useState<{ top: number; right: number } | null>(null)
const btnRef = useRef<HTMLButtonElement>(null)
// The dropdown is portaled to <body> and fixed-positioned from the button so it
// can't be clipped by the packing panel's overflow.
const toggle = () => {
if (open) { setOpen(false); return }
const r = btnRef.current?.getBoundingClientRect()
if (r) setPos({ top: r.bottom + 4, right: window.innerWidth - r.right })
setOpen(true)
}
const isCommon = !item.is_private
const isOwner = item.owner_id == null || item.owner_id === currentUserId
const recipientIds = (item.recipients || []).map(r => r.user_id)
const visibility: 'common' | 'personal' | 'shared' = isCommon ? 'common' : recipientIds.length > 0 ? 'shared' : 'personal'
const iAmContributor = (item.contributors || []).some(c => c.user_id === currentUserId)
const others = tripMembers.filter(m => m.id !== item.owner_id && m.id !== currentUserId)
const toggleRecipient = (uid: number) => {
const next = recipientIds.includes(uid) ? recipientIds.filter(x => x !== uid) : [...recipientIds, uid]
onSetSharing(item.id, 'shared', next)
}
const btn = (onClick: () => void, title: string, active: boolean, node: React.ReactNode) => (
<button onClick={onClick} title={title}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: active ? 'var(--accent)' : 'var(--text-faint)' }}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-secondary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}>
{node}
</button>
)
// Non-owner on a Common item: pledge to co-bring + clone to personal list.
if (!isOwner && isCommon) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{btn(() => (iAmContributor ? onLeave(item.id, currentUserId!) : onJoin(item.id)),
iAmContributor ? t('packing.alsoBringingStop') : t('packing.alsoBring'), iAmContributor, <HandHelping size={14} />)}
{btn(() => onClone(item.id), t('packing.cloneToMine'), false, <Copy size={13} />)}
</div>
)
}
// A recipient of a shared item has no controls (it's the owner's responsibility).
if (!isOwner) return null
return (
<div style={{ display: 'flex' }}>
<button ref={btnRef} onClick={toggle} title={t('packing.share')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: visibility !== 'common' ? 'var(--accent)' : 'var(--text-faint)' }}
onMouseEnter={e => { if (visibility === 'common') e.currentTarget.style.color = 'var(--text-secondary)' }}
onMouseLeave={e => { if (visibility === 'common') e.currentTarget.style.color = 'var(--text-faint)' }}>
<Share2 size={14} />
</button>
{open && pos && ReactDOM.createPortal(
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 1099 }} onClick={() => setOpen(false)} />
<div style={{
position: 'fixed', top: pos.top, right: pos.right, zIndex: 1100,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 8px 28px rgba(0,0,0,0.18)', padding: 4, minWidth: 200, maxHeight: '60vh', overflowY: 'auto',
}}>
<Row icon={<Users size={13} />} label={t('packing.viewCommon')} sub={t('packing.tierCommonHint')} active={visibility === 'common'} onClick={() => { onSetSharing(item.id, 'common', []); setOpen(false) }} />
<Row icon={<UserRound size={13} />} label={t('packing.tierPersonal')} sub={t('packing.tierPersonalHint')} active={visibility === 'personal'} onClick={() => { onSetSharing(item.id, 'personal', []); setOpen(false) }} />
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
<div style={{ padding: '4px 10px 2px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 5 }}>
<Share2 size={10} /> {t('packing.tierShared')}
</div>
{others.length === 0 ? (
<div style={{ padding: '4px 10px 6px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{t('packing.noOneToShare')}</div>
) : others.map(m => {
const on = recipientIds.includes(m.id)
return (
<button key={m.id} onClick={() => toggleRecipient(m.id)}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px', borderRadius: 7, border: 'none', cursor: 'pointer', background: 'none', fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-primary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<span style={{ width: 18, height: 18, borderRadius: '50%', flexShrink: 0, background: `hsl(${m.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'white', textTransform: 'uppercase' }}>{m.username[0]}</span>
<span style={{ flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis' }}>{m.username}</span>
{on && <Check size={13} className="text-content-muted" />}
</button>
)
})}
</div>
</>,
document.body,
)}
</div>
)
}
function Row({ icon, label, sub, active, onClick }: { icon: React.ReactNode; label: string; sub: string; active: boolean; onClick: () => void }) {
return (
<button onClick={onClick}
style={{ display: 'flex', alignItems: 'flex-start', gap: 8, width: '100%', padding: '7px 10px', borderRadius: 7, border: 'none', cursor: 'pointer', background: active ? 'var(--bg-tertiary)' : 'none', fontFamily: 'inherit', textAlign: 'left' }}
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.background = active ? 'var(--bg-tertiary)' : 'none' }}>
<span style={{ color: active ? 'var(--accent)' : 'var(--text-muted)', marginTop: 1 }}>{icon}</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{label}</span>
<span style={{ display: 'block', fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{sub}</span>
</span>
{active && <Check size={13} className="text-content-muted" style={{ marginTop: 2 }} />}
</button>
)
}
@@ -44,13 +44,18 @@ export interface PackingListPanelProps {
*/
export function usePackingList({ tripId, items, openImportSignal = 0, clearCheckedSignal = 0, saveTemplateSignal = 0, inlineHeader = true }: PackingListPanelProps) {
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
// Three-tier sharing (#858): 'common' = the group pool (where existing items
// live — non-breaking), 'personal' = my own list (private + shared-to-me).
const [view, setView] = useState<'common' | 'personal'>('common')
const [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('')
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem, reorderPackingItems } = useTripStore()
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem, reorderPackingItems,
setPackingItemSharing, clonePackingItem, addPackingContributor, removePackingContributor } = useTripStore()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('packing_edit', trip)
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
const currentUserId = useAuthStore((s) => s.user?.id)
const toast = useToast()
const { t } = useTranslation()
@@ -79,17 +84,24 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
}
}
// Split by the active view (#858): Common = group pool (is_private 0), Personal =
// my own + shared-to-me (is_private 1, already filtered to me by the server).
const viewItems = useMemo(
() => items.filter(i => (view === 'common' ? !i.is_private : !!i.is_private)),
[items, view],
)
const allCategories = useMemo(() => {
const seen: string[] = []
for (const item of items) {
for (const item of viewItems) {
const cat = item.category || t('packing.defaultCategory')
if (!seen.includes(cat)) seen.push(cat)
}
return seen
}, [items, t])
}, [viewItems, t])
const gruppiert = useMemo(() => {
const filtered = items.filter(i => {
const filtered = viewItems.filter(i => {
if (filter === 'offen') return !i.checked
if (filter === 'erledigt') return i.checked
return true
@@ -101,10 +113,10 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
groups[kat].push(item)
}
return groups
}, [items, filter, t])
}, [viewItems, filter, t])
const abgehakt = items.filter(i => i.checked).length
const fortschritt = items.length > 0 ? Math.round((abgehakt / items.length) * 100) : 0
const abgehakt = viewItems.filter(i => i.checked).length
const fortschritt = viewItems.length > 0 ? Math.round((abgehakt / viewItems.length) * 100) : 0
const handleAddItemToCategory = async (category: string, name: string) => {
try {
@@ -117,7 +129,8 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
if (placeholder) {
await updatePackingItem(tripId, placeholder.id, { name })
} else {
await addPackingItem(tripId, { name, category })
// New items inherit the active view's tier: Personal in "my list", Common otherwise.
await addPackingItem(tripId, { name, category, visibility: view === 'personal' ? 'personal' : 'common' } as Parameters<typeof addPackingItem>[1])
}
} catch { toast.error(t('packing.toast.addError')) }
}
@@ -155,7 +168,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
catName += ''
}
try {
await addPackingItem(tripId, { name: '...', category: catName })
await addPackingItem(tripId, { name: '...', category: catName, visibility: view === 'personal' ? 'personal' : 'common' } as Parameters<typeof addPackingItem>[1])
setNewCatName('')
setAddingCategory(false)
} catch { toast.error(t('packing.toast.addError')) }
@@ -341,7 +354,16 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const font = { fontFamily: "var(--font-system)" }
// ── Three-tier sharing handlers (#858) ──────────────────────────────────────
const handleSetSharing = (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) =>
setPackingItemSharing(tripId, id, visibility, recipientIds)
const handleCloneItem = (id: number) => clonePackingItem(tripId, id)
const handleJoinItem = (id: number) => addPackingContributor(tripId, id)
const handleLeaveItem = (id: number, userId: number) => removePackingContributor(tripId, id, userId)
return {
view, setView, currentUserId,
handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem,
tripId, items, inlineHeader, t, canEdit, isAdmin, font, reorderPackingItems,
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
+43
View File
@@ -15,6 +15,11 @@ export interface PackingSlice {
deletePackingItem: (tripId: number | string, id: number) => Promise<void>
togglePackingItem: (tripId: number | string, id: number, checked: boolean) => Promise<void>
reorderPackingItems: (tripId: number | string, orderedIds: number[]) => Promise<void>
// Three-tier sharing (#858)
setPackingItemSharing: (tripId: number | string, id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => Promise<void>
clonePackingItem: (tripId: number | string, id: number) => Promise<void>
addPackingContributor: (tripId: number | string, id: number) => Promise<void>
removePackingContributor: (tripId: number | string, id: number, userId: number) => Promise<void>
}
export const createPackingSlice = (set: SetState, get: GetState): PackingSlice => ({
@@ -90,4 +95,42 @@ export const createPackingSlice = (set: SetState, get: GetState): PackingSlice =
notify(getApiErrorMessage(err, 'Error reordering items'), 'error')
}
},
// ── Three-tier sharing (#858) ──────────────────────────────────────────────
setPackingItemSharing: async (tripId, id, visibility, recipientIds) => {
try {
const result = await packingApi.setSharing(tripId, id, { visibility, recipient_ids: recipientIds })
set(state => ({ packingItems: state.packingItems.map(i => i.id === id ? result.item : i) }))
} catch (err: unknown) {
notify(getApiErrorMessage(err, 'Error updating sharing'), 'error')
throw err
}
},
clonePackingItem: async (tripId, id) => {
try {
const result = await packingApi.clone(tripId, id)
set(state => (state.packingItems.some(i => i.id === result.item.id) ? {} : { packingItems: [...state.packingItems, result.item] }))
} catch (err: unknown) {
notify(getApiErrorMessage(err, 'Error copying item'), 'error')
}
},
addPackingContributor: async (tripId, id) => {
try {
const result = await packingApi.addContributor(tripId, id)
set(state => ({ packingItems: state.packingItems.map(i => i.id === id ? result.item : i) }))
} catch (err: unknown) {
notify(getApiErrorMessage(err, 'Error joining item'), 'error')
}
},
removePackingContributor: async (tripId, id, userId) => {
try {
const result = await packingApi.removeContributor(tripId, id, userId)
set(state => ({ packingItems: state.packingItems.map(i => i.id === id ? result.item : i) }))
} catch (err: unknown) {
notify(getApiErrorMessage(err, 'Error leaving item'), 'error')
}
},
})
+22
View File
@@ -3158,6 +3158,28 @@ function runMigrations(db: Database.Database): void {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
// Three-tier packing sharing (#858 follow-up): an item is Common (is_private=0,
// every existing item — non-breaking), Personal (is_private=1, owner only) or
// Shared-with-people (is_private=1 + recipient rows). owner_id is the "bringer".
// Contributors are extra people who said "I can bring that too" on a Common item
// (status 'pending' until the owner accepts).
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS packing_item_recipients (
item_id INTEGER NOT NULL REFERENCES packing_items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (item_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_packing_item_recipients_user ON packing_item_recipients(user_id);
CREATE TABLE IF NOT EXISTS packing_item_contributors (
item_id INTEGER NOT NULL REFERENCES packing_items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'accepted',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (item_id, user_id)
);
`);
},
];
if (currentVersion < migrations.length) {
+101 -6
View File
@@ -18,7 +18,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
/** A packing item row carrying the privacy fields (#858) used to scope broadcasts. */
type PackingItemRow = { is_private?: number; owner_id?: number | null; [key: string]: unknown };
type PackingItemRow = { is_private?: number; owner_id?: number | null; recipients?: { user_id: number }[]; [key: string]: unknown };
/**
* /api/trips/:tripId/packing trip-scoped packing list (items, bags, templates,
@@ -81,7 +81,7 @@ export class PackingController {
create(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: { name?: string; category?: string; checked?: boolean; is_private?: boolean },
@Body() body: { name?: string; category?: string; checked?: boolean; is_private?: boolean; visibility?: 'common' | 'personal' | 'shared'; recipient_ids?: number[] },
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
@@ -89,11 +89,22 @@ export class PackingController {
if (!body.name) {
throw new HttpException({ error: 'Item name is required' }, 400);
}
const item = this.packing.createItem(tripId, { name: body.name, category: body.category, checked: body.checked, is_private: body.is_private }, user.id);
this.packing.broadcastItem(tripId, 'packing:created', { item }, item, socketId);
const item = this.packing.createItem(tripId, { name: body.name, category: body.category, checked: body.checked, is_private: body.is_private, visibility: body.visibility, recipient_ids: body.recipient_ids }, user.id);
this.emitToViewers(tripId, 'packing:created', { item }, item, socketId);
return { item };
}
/** Deliver an item event to exactly the people who can see it (#858): the whole
* room for a Common item, or owner + recipients for a restricted one. */
private emitToViewers(tripId: string, event: string, payload: Record<string, unknown>, item: PackingItemRow, socketId: string | undefined): void {
const viewers = this.packing.viewersOf(item);
if (viewers === null) {
this.packing.broadcast(tripId, event, payload, socketId);
} else {
this.packing.broadcastToViewers(tripId, event, payload, viewers, socketId);
}
}
@Put('reorder')
reorder(
@CurrentUser() user: User,
@@ -178,11 +189,95 @@ export class PackingController {
if (!deleted) {
throw new HttpException({ error: 'Item not found' }, 404);
}
// Scope the delete to the owner when the item was private (#858).
this.packing.broadcastItem(tripId, 'packing:deleted', { itemId: Number(id) }, deleted, socketId);
// Scope the delete to the people who could see it (owner + recipients, #858).
this.emitToViewers(tripId, 'packing:deleted', { itemId: Number(id) }, deleted as PackingItemRow, socketId);
return { success: true };
}
@Put(':id/sharing')
setSharing(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: { visibility?: 'common' | 'personal' | 'shared'; recipient_ids?: number[] },
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
if (body.visibility !== 'common' && body.visibility !== 'personal' && body.visibility !== 'shared') {
throw new HttpException({ error: 'Invalid visibility' }, 400);
}
const updated = this.packing.setItemSharing(tripId, id, user.id, body.visibility, Array.isArray(body.recipient_ids) ? body.recipient_ids : []);
if (!updated) {
throw new HttpException({ error: 'Item not found' }, 404);
}
if ((updated as { forbidden?: boolean }).forbidden) {
throw new HttpException({ error: 'Only the owner can change sharing' }, 403);
}
// The viewer set just changed: drop the item from the whole room, then re-add
// it for whoever can now see it (owner + recipients, or everyone if Common).
this.packing.broadcast(tripId, 'packing:deleted', { itemId: Number(id) }, socketId);
this.emitToViewers(tripId, 'packing:created', { item: updated }, updated as PackingItemRow, socketId);
return { item: updated };
}
@Post(':id/clone')
@HttpCode(201)
clone(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
const item = this.packing.cloneItem(tripId, id, user.id);
if (!item) {
throw new HttpException({ error: 'Item not found' }, 404);
}
// The clone is personal to the caller — only their sockets need it.
this.emitToViewers(tripId, 'packing:created', { item }, item, socketId);
return { item };
}
@Post(':id/contributors')
addContributor(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
const item = this.packing.addContributor(tripId, id, user.id);
if (!item) {
throw new HttpException({ error: 'Item not found or not a shared list item' }, 404);
}
// Common item — visible to all, so the contributor change broadcasts to the room.
this.packing.broadcast(tripId, 'packing:updated', { item }, socketId);
return { item };
}
@Delete(':id/contributors/:userId')
removeContributor(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Param('userId') userId: string,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
// You can drop your own pledge; the owner can remove anyone's.
const target = parseInt(userId);
const item = this.packing.removeContributor(tripId, id, target);
if (!item) {
throw new HttpException({ error: 'Item not found' }, 404);
}
this.packing.broadcast(tripId, 'packing:updated', { item }, socketId);
return { item };
}
@Get('bags')
listBags(@CurrentUser() user: User, @Param('tripId') tripId: string) {
this.requireTrip(tripId, user);
+33 -1
View File
@@ -40,6 +40,22 @@ export class PackingService {
broadcast(tripId, event, payload, socketId, onlyUserId);
}
/** Deliver an item event to a specific set of viewers (#858 shared items) the
* owner plus the recipients it was shared with without leaking to the room. */
broadcastToViewers(tripId: string, event: string, payload: Record<string, unknown>, viewerIds: number[], socketId: string | undefined): void {
for (const uid of new Set(viewerIds)) {
if (uid != null) broadcast(tripId, event, payload, socketId, uid);
}
}
/** The users who can currently see an item: everyone (null) for Common, or
* owner + recipients for a restricted item. */
viewersOf(item: { is_private?: number; owner_id?: number | null; recipients?: { user_id: number }[] } | null | undefined): number[] | null {
if (!item || !item.is_private) return null; // Common — visible to the whole room
const ids = [item.owner_id, ...(item.recipients || []).map(r => r.user_id)].filter((x): x is number => x != null);
return ids;
}
listItems(tripId: string, userId?: number) {
return svc.listItems(tripId, userId);
}
@@ -50,10 +66,26 @@ export class PackingService {
return db.prepare('SELECT is_private, owner_id FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId) as PrivacyFields | undefined;
}
createItem(tripId: string, data: { name: string; category?: string; checked?: boolean; is_private?: boolean }, ownerId?: number) {
createItem(tripId: string, data: Parameters<typeof svc.createItem>[1], ownerId?: number) {
return svc.createItem(tripId, data, ownerId);
}
setItemSharing(tripId: string, id: string, actingUserId: number, visibility: svc.PackingVisibility, recipientIds: number[]) {
return svc.setItemSharing(tripId, id, actingUserId, visibility, recipientIds);
}
addContributor(tripId: string, id: string, userId: number) {
return svc.addContributor(tripId, id, userId);
}
removeContributor(tripId: string, id: string, userId: number) {
return svc.removeContributor(tripId, id, userId);
}
cloneItem(tripId: string, id: string, userId: number) {
return svc.cloneItem(tripId, id, userId);
}
updateItem(tripId: string, id: string, data: Parameters<typeof svc.updateItem>[2], changedKeys: string[], ifMatch?: string, actingUserId?: number) {
return svc.updateItem(tripId, id, data, changedKeys, ifMatch, actingUserId);
}
+155 -17
View File
@@ -8,30 +8,105 @@ export { verifyTripAccess } from './tripAccess';
// ── Items ──────────────────────────────────────────────────────────────────
export function listItems(tripId: string | number, userId?: number) {
// Private items (#858) are visible only to their owner; shared items
// (is_private = 0) are visible to everyone. Without a userId (internal callers
// such as trip export) the unfiltered list is returned for back-compat.
if (userId == null) {
return db.prepare(
'SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC'
).all(tripId);
/**
* Attach the bringer name, recipients and co-contributors to a set of packing
* items (#858 three-tier sharing). Batched so the list endpoint stays one round
* of queries regardless of item count.
*/
function enrichItems(items: any[]): any[] {
if (items.length === 0) return items;
const ids = items.map(i => i.id);
const placeholders = ids.map(() => '?').join(',');
const owners = db.prepare(`SELECT id, username FROM users WHERE id IN (SELECT owner_id FROM packing_items WHERE id IN (${placeholders}))`).all(...ids) as { id: number; username: string }[];
const ownerName = new Map(owners.map(o => [o.id, o.username]));
const recipientRows = db.prepare(`
SELECT r.item_id, r.user_id, u.username
FROM packing_item_recipients r JOIN users u ON u.id = r.user_id
WHERE r.item_id IN (${placeholders})
`).all(...ids) as { item_id: number; user_id: number; username: string }[];
const recipientsByItem = new Map<number, { user_id: number; username: string }[]>();
for (const r of recipientRows) {
if (!recipientsByItem.has(r.item_id)) recipientsByItem.set(r.item_id, []);
recipientsByItem.get(r.item_id)!.push({ user_id: r.user_id, username: r.username });
}
return db.prepare(
'SELECT * FROM packing_items WHERE trip_id = ? AND (is_private = 0 OR owner_id = ?) ORDER BY sort_order ASC, created_at ASC'
).all(tripId, userId);
const contributorRows = db.prepare(`
SELECT c.item_id, c.user_id, c.status, u.username
FROM packing_item_contributors c JOIN users u ON u.id = c.user_id
WHERE c.item_id IN (${placeholders})
`).all(...ids) as { item_id: number; user_id: number; status: string; username: string }[];
const contributorsByItem = new Map<number, { user_id: number; username: string; status: string }[]>();
for (const c of contributorRows) {
if (!contributorsByItem.has(c.item_id)) contributorsByItem.set(c.item_id, []);
contributorsByItem.get(c.item_id)!.push({ user_id: c.user_id, username: c.username, status: c.status });
}
return items.map(i => ({
...i,
owner_username: i.owner_id != null ? ownerName.get(i.owner_id) ?? null : null,
recipients: recipientsByItem.get(i.id) || [],
contributors: contributorsByItem.get(i.id) || [],
}));
}
export function createItem(tripId: string | number, data: { name: string; category?: string; checked?: boolean; quantity?: number; is_private?: boolean }, ownerId?: number) {
export function listItems(tripId: string | number, userId?: number) {
// Three-tier visibility (#858): Common (is_private=0) is visible to everyone;
// Personal/Shared (is_private=1) only to the owner (bringer) and the recipients
// it was explicitly shared with. Without a userId (internal callers such as
// trip export) the unfiltered list is returned for back-compat.
let rows: any[];
if (userId == null) {
rows = db.prepare(
'SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC'
).all(tripId) as any[];
} else {
rows = db.prepare(`
SELECT * FROM packing_items
WHERE trip_id = ?
AND (is_private = 0
OR owner_id = ?
OR EXISTS (SELECT 1 FROM packing_item_recipients r WHERE r.item_id = packing_items.id AND r.user_id = ?))
ORDER BY sort_order ASC, created_at ASC
`).all(tripId, userId, userId) as any[];
}
return enrichItems(rows);
}
export type PackingVisibility = 'common' | 'personal' | 'shared';
/** Maps the three-tier visibility (#858) onto the stored is_private flag. */
function visibilityToPrivate(visibility?: PackingVisibility, isPrivateFallback?: boolean): number {
if (visibility) return visibility === 'common' ? 0 : 1;
return isPrivateFallback ? 1 : 0;
}
export function createItem(
tripId: string | number,
data: { name: string; category?: string; checked?: boolean; quantity?: number; is_private?: boolean; visibility?: PackingVisibility; recipient_ids?: number[] },
ownerId?: number,
) {
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const qty = Math.max(1, Math.min(999, Number(data.quantity) || 1));
const isPrivate = visibilityToPrivate(data.visibility, data.is_private);
const result = db.prepare(
'INSERT INTO packing_items (trip_id, name, checked, category, sort_order, quantity, is_private, owner_id, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)'
).run(tripId, data.name, data.checked ? 1 : 0, data.category || 'Allgemein', sortOrder, qty, data.is_private ? 1 : 0, ownerId ?? null);
const create = db.transaction(() => {
const result = db.prepare(
'INSERT INTO packing_items (trip_id, name, checked, category, sort_order, quantity, is_private, owner_id, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)'
).run(tripId, data.name, data.checked ? 1 : 0, data.category || 'Allgemein', sortOrder, qty, isPrivate, ownerId ?? null);
const itemId = Number(result.lastInsertRowid);
// "Shared with specific people" — record the recipients it covers.
if (data.visibility === 'shared' && Array.isArray(data.recipient_ids)) {
const ins = db.prepare('INSERT OR IGNORE INTO packing_item_recipients (item_id, user_id) VALUES (?, ?)');
for (const uid of data.recipient_ids) if (uid !== ownerId) ins.run(itemId, uid);
}
return itemId;
});
const itemId = create();
return db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid);
return enrichItems([db.prepare('SELECT * FROM packing_items WHERE id = ?').get(itemId)])[0];
}
export function updateItem(
@@ -85,7 +160,70 @@ export function updateItem(
id
);
return db.prepare('SELECT * FROM packing_items WHERE id = ?').get(id);
return enrichItems([db.prepare('SELECT * FROM packing_items WHERE id = ?').get(id)])[0];
}
// ── Three-tier sharing (#858): recipients, contributors, clone ───────────────
/** Loads an item scoped to its trip (the trip-access check happens in the controller). */
function getItemInTrip(tripId: string | number, id: string | number) {
return db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId) as
{ id: number; owner_id: number | null; is_private: number; name: string; category: string | null; quantity: number } | undefined;
}
/**
* Re-set who a "shared with specific people" item covers, and its visibility tier.
* Only the owner (bringer) may change this; a non-owner caller is rejected with null.
*/
export function setItemSharing(
tripId: string | number,
id: string | number,
actingUserId: number,
visibility: PackingVisibility,
recipientIds: number[],
) {
const item = getItemInTrip(tripId, id);
if (!item) return null;
// The owner controls sharing; an unowned legacy item is claimed by the actor.
if (item.owner_id != null && item.owner_id !== actingUserId) return { forbidden: true as const };
const run = db.transaction(() => {
db.prepare('UPDATE packing_items SET is_private = ?, owner_id = COALESCE(owner_id, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ?')
.run(visibilityToPrivate(visibility), actingUserId, id);
db.prepare('DELETE FROM packing_item_recipients WHERE item_id = ?').run(id);
if (visibility === 'shared') {
const ins = db.prepare('INSERT OR IGNORE INTO packing_item_recipients (item_id, user_id) VALUES (?, ?)');
const owner = item.owner_id ?? actingUserId;
for (const uid of recipientIds) if (uid !== owner) ins.run(id, uid);
}
// Leaving the Common tier drops any co-contributors (they only apply to Common).
if (visibility !== 'common') db.prepare('DELETE FROM packing_item_contributors WHERE item_id = ?').run(id);
});
run();
return enrichItems([db.prepare('SELECT * FROM packing_items WHERE id = ?').get(id)])[0];
}
/** "I can bring that too" — adds the user as a co-contributor on a Common item. */
export function addContributor(tripId: string | number, id: string | number, userId: number) {
const item = getItemInTrip(tripId, id);
if (!item || item.is_private !== 0) return null; // co-contribution is a Common-list concept
if (item.owner_id === userId) return null; // the bringer is already covering it
db.prepare("INSERT OR IGNORE INTO packing_item_contributors (item_id, user_id, status) VALUES (?, ?, 'accepted')").run(id, userId);
return enrichItems([db.prepare('SELECT * FROM packing_items WHERE id = ?').get(id)])[0];
}
export function removeContributor(tripId: string | number, id: string | number, userId: number) {
const item = getItemInTrip(tripId, id);
if (!item) return null;
db.prepare('DELETE FROM packing_item_contributors WHERE item_id = ? AND user_id = ?').run(id, userId);
return enrichItems([db.prepare('SELECT * FROM packing_items WHERE id = ?').get(id)])[0];
}
/** Clone a (Common) item onto the caller's Personal list as a private starting point. */
export function cloneItem(tripId: string | number, id: string | number, userId: number) {
const item = getItemInTrip(tripId, id);
if (!item) return null;
return createItem(tripId, { name: item.name, category: item.category || undefined, quantity: item.quantity, visibility: 'personal' }, userId);
}
export function deleteItem(tripId: string | number, id: string | number) {
+76
View File
@@ -186,6 +186,82 @@ describe('Private packing items (#858)', () => {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Three-tier sharing (#858)
// ─────────────────────────────────────────────────────────────────────────────
describe('Three-tier packing sharing (#858)', () => {
it('PACK-3T-001 — existing items stay Common (visible to all) — non-breaking', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
// A pre-existing row (default is_private=0) must remain visible to everyone.
createPackingItem(testDb, trip.id, { name: 'Group tent' });
const memberView = await request(app).get(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(member.id));
expect(memberView.body.items.map((i: any) => i.name)).toContain('Group tent');
});
it('PACK-3T-002 — a Shared item reaches the recipient (with the bringer) but no one else', async () => {
const { user: owner } = createUser(testDb);
const { user: friend } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, friend.id);
addTripMember(testDb, trip.id, stranger.id);
const created = await request(app).post(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(owner.id))
.send({ name: 'Power bank', visibility: 'shared', recipient_ids: [friend.id] });
expect(created.body.item.recipients.map((r: any) => r.user_id)).toEqual([friend.id]);
expect(created.body.item.owner_username).toBeTruthy();
const friendView = await request(app).get(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(friend.id));
const strangerView = await request(app).get(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(stranger.id));
expect(friendView.body.items.map((i: any) => i.name)).toContain('Power bank');
expect(strangerView.body.items.map((i: any) => i.name)).not.toContain('Power bank');
});
it('PACK-3T-003 — clone copies a Common item onto the caller\'s personal list', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const created = await request(app).post(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(owner.id)).send({ name: 'Adapter', visibility: 'common' });
const clone = await request(app).post(`/api/trips/${trip.id}/packing/${created.body.item.id}/clone`).set('Cookie', authCookie(member.id));
expect(clone.status).toBe(201);
expect(clone.body.item.is_private).toBe(1);
expect(clone.body.item.owner_id).toBe(member.id);
// The owner does not see the member's private clone.
const ownerView = await request(app).get(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(owner.id));
expect(ownerView.body.items.filter((i: any) => i.name === 'Adapter')).toHaveLength(1);
});
it('PACK-3T-004 — "I can bring that too" adds the caller as a contributor on a Common item', async () => {
const { user: owner } = createUser(testDb);
const { user: helper } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, helper.id);
const created = await request(app).post(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(owner.id)).send({ name: 'Sunscreen', visibility: 'common' });
const res = await request(app).post(`/api/trips/${trip.id}/packing/${created.body.item.id}/contributors`).set('Cookie', authCookie(helper.id));
expect(res.status).toBe(201);
expect(res.body.item.contributors.map((c: any) => c.user_id)).toContain(helper.id);
});
it('PACK-3T-005 — sharing can only be changed by the owner', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const created = await request(app).post(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(owner.id)).send({ name: 'Tent', visibility: 'personal' });
const denied = await request(app).put(`/api/trips/${trip.id}/packing/${created.body.item.id}/sharing`).set('Cookie', authCookie(member.id)).send({ visibility: 'common' });
expect(denied.status).toBe(403);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Update packing item
// ─────────────────────────────────────────────────────────────────────────────
@@ -15,6 +15,10 @@ function makeService(overrides: Partial<PackingService> = {}): PackingService {
canEdit: vi.fn().mockReturnValue(true),
broadcast: vi.fn(),
broadcastItem: vi.fn(),
broadcastToViewers: vi.fn(),
// Real viewer logic so the emit-to-viewers routing is exercised faithfully.
viewersOf: (item: { is_private?: number; owner_id?: number | null; recipients?: { user_id: number }[] } | null | undefined) =>
!item || !item.is_private ? null : [item.owner_id, ...(item.recipients || []).map(r => r.user_id)].filter((x): x is number => x != null),
getItemPrivacy: vi.fn().mockReturnValue(undefined),
notifyTagged: vi.fn(),
...overrides,
@@ -60,14 +64,24 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
});
});
it('creates an item (owned by the creator) and broadcasts', () => {
const createItem = vi.fn().mockReturnValue({ id: 9, name: 'Socks' });
const broadcastItem = vi.fn();
const svc = makeService({ createItem, broadcastItem } as Partial<PackingService>);
expect(new PackingController(svc).create(user, '5', { name: 'Socks' }, 'sock')).toEqual({ item: { id: 9, name: 'Socks' } });
// Stamps the creator as owner (#858) and routes the broadcast through broadcastItem.
it('creates an item (owned by the creator) and broadcasts a Common item to the room', () => {
// Common item (is_private falsy) → viewersOf null → whole-room broadcast.
const createItem = vi.fn().mockReturnValue({ id: 9, name: 'Socks', is_private: 0 });
const broadcast = vi.fn();
const svc = makeService({ createItem, broadcast } as Partial<PackingService>);
expect(new PackingController(svc).create(user, '5', { name: 'Socks' }, 'sock')).toEqual({ item: { id: 9, name: 'Socks', is_private: 0 } });
// Stamps the creator as owner (#858) and routes the broadcast to viewers.
expect(createItem).toHaveBeenCalledWith('5', expect.objectContaining({ name: 'Socks' }), user.id);
expect(broadcastItem).toHaveBeenCalledWith('5', 'packing:created', { item: { id: 9, name: 'Socks' } }, { id: 9, name: 'Socks' }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: { id: 9, name: 'Socks', is_private: 0 } }, 'sock');
});
it('routes a Shared item create only to the owner + recipients (#858)', () => {
const item = { id: 9, name: 'Power bank', is_private: 1, owner_id: 1, recipients: [{ user_id: 2 }] };
const createItem = vi.fn().mockReturnValue(item);
const broadcastToViewers = vi.fn();
const svc = makeService({ createItem, broadcastToViewers } as Partial<PackingService>);
new PackingController(svc).create(user, '5', { name: 'Power bank', visibility: 'shared', recipient_ids: [2] }, 'sock');
expect(broadcastToViewers).toHaveBeenCalledWith('5', 'packing:created', { item }, [1, 2], 'sock');
});
});
@@ -202,13 +216,72 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
});
});
it('deletes the item and broadcasts (scoped to the owner when private, #858)', () => {
it('deletes a Common item and broadcasts to the room', () => {
const deleted = { id: 9, is_private: 0, owner_id: 1 };
const deleteItem = vi.fn().mockReturnValue(deleted);
const broadcastItem = vi.fn();
const svc = makeService({ deleteItem, broadcastItem } as Partial<PackingService>);
const broadcast = vi.fn();
const svc = makeService({ deleteItem, broadcast } as Partial<PackingService>);
expect(new PackingController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true });
expect(broadcastItem).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, deleted, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, 'sock');
});
it('scopes the delete of a Shared item to the owner + recipients (#858)', () => {
const deleted = { id: 9, is_private: 1, owner_id: 1, recipients: [{ user_id: 2 }] };
const deleteItem = vi.fn().mockReturnValue(deleted);
const broadcastToViewers = vi.fn();
const svc = makeService({ deleteItem, broadcastToViewers } as Partial<PackingService>);
new PackingController(svc).remove(user, '5', '9', 'sock');
expect(broadcastToViewers).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, [1, 2], 'sock');
});
});
describe('sharing, contributors, clone (#858 three-tier)', () => {
it('PUT /:id/sharing 400 invalid, 404 missing, 403 non-owner, else drops + re-emits', () => {
expect(thrown(() => new PackingController(makeService()).setSharing(user, '5', '9', { visibility: 'nope' as never }))).toEqual({ status: 400, body: { error: 'Invalid visibility' } });
expect(thrown(() => new PackingController(makeService({ setItemSharing: vi.fn().mockReturnValue(null) } as Partial<PackingService>)).setSharing(user, '5', '9', { visibility: 'personal' }))).toEqual({ status: 404, body: { error: 'Item not found' } });
expect(thrown(() => new PackingController(makeService({ setItemSharing: vi.fn().mockReturnValue({ forbidden: true }) } as Partial<PackingService>)).setSharing(user, '5', '9', { visibility: 'personal' }))).toEqual({ status: 403, body: { error: 'Only the owner can change sharing' } });
const updated = { id: 9, is_private: 1, owner_id: 1, recipients: [{ user_id: 2 }] };
const setItemSharing = vi.fn().mockReturnValue(updated);
const broadcast = vi.fn();
const broadcastToViewers = vi.fn();
const svc = makeService({ setItemSharing, broadcast, broadcastToViewers } as Partial<PackingService>);
new PackingController(svc).setSharing(user, '5', '9', { visibility: 'shared', recipient_ids: [2] }, 'sock');
expect(setItemSharing).toHaveBeenCalledWith('5', '9', user.id, 'shared', [2]);
expect(broadcast).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, 'sock');
expect(broadcastToViewers).toHaveBeenCalledWith('5', 'packing:created', { item: updated }, [1, 2], 'sock');
});
it('POST /:id/clone 404 missing, else creates a personal copy for the caller', () => {
expect(thrown(() => new PackingController(makeService({ cloneItem: vi.fn().mockReturnValue(null) } as Partial<PackingService>)).clone(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Item not found' } });
const item = { id: 12, is_private: 1, owner_id: 1 };
const cloneItem = vi.fn().mockReturnValue(item);
const broadcastToViewers = vi.fn();
const svc = makeService({ cloneItem, broadcastToViewers } as Partial<PackingService>);
expect(new PackingController(svc).clone(user, '5', '9', 'sock')).toEqual({ item });
expect(cloneItem).toHaveBeenCalledWith('5', '9', user.id);
expect(broadcastToViewers).toHaveBeenCalledWith('5', 'packing:created', { item }, [1], 'sock');
});
it('POST /:id/contributors 404 missing, else adds the caller + broadcasts', () => {
expect(thrown(() => new PackingController(makeService({ addContributor: vi.fn().mockReturnValue(null) } as Partial<PackingService>)).addContributor(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Item not found or not a shared list item' } });
const item = { id: 9, is_private: 0, contributors: [{ user_id: 1 }] };
const addContributor = vi.fn().mockReturnValue(item);
const broadcast = vi.fn();
const svc = makeService({ addContributor, broadcast } as Partial<PackingService>);
new PackingController(svc).addContributor(user, '5', '9', 'sock');
expect(addContributor).toHaveBeenCalledWith('5', '9', user.id);
expect(broadcast).toHaveBeenCalledWith('5', 'packing:updated', { item }, 'sock');
});
it('DELETE /:id/contributors/:userId removes the contributor + broadcasts', () => {
const item = { id: 9, is_private: 0, contributors: [] };
const removeContributor = vi.fn().mockReturnValue(item);
const broadcast = vi.fn();
const svc = makeService({ removeContributor, broadcast } as Partial<PackingService>);
new PackingController(svc).removeContributor(user, '5', '9', '2', 'sock');
expect(removeContributor).toHaveBeenCalledWith('5', '9', 2);
expect(broadcast).toHaveBeenCalledWith('5', 'packing:updated', { item }, 'sock');
});
});
@@ -18,6 +18,7 @@ const { pk } = vi.hoisted(() => ({
bulkImport: vi.fn(), listBags: vi.fn(), createBag: vi.fn(), updateBag: vi.fn(), deleteBag: vi.fn(),
listTemplates: vi.fn(), applyTemplate: vi.fn(), saveAsTemplate: vi.fn(), setBagMembers: vi.fn(), getCategoryAssignees: vi.fn(),
updateCategoryAssignees: vi.fn(), reorderItems: vi.fn(),
setItemSharing: vi.fn(), addContributor: vi.fn(), removeContributor: vi.fn(), cloneItem: vi.fn(),
},
}));
vi.mock('../../../src/services/packingService', () => pk);
@@ -86,6 +87,25 @@ describe('PackingService (wrapper delegation + helpers)', () => {
s.saveAsTemplate('5', 1, 'Tpl'); expect(pk.saveAsTemplate).toHaveBeenCalledWith('5', 1, 'Tpl');
s.getCategoryAssignees('5'); expect(pk.getCategoryAssignees).toHaveBeenCalledWith('5');
s.updateCategoryAssignees('5', 'Clothes', [2]); expect(pk.updateCategoryAssignees).toHaveBeenCalledWith('5', 'Clothes', [2]);
s.setItemSharing('5', '2', 1, 'shared', [3]); expect(pk.setItemSharing).toHaveBeenCalledWith('5', '2', 1, 'shared', [3]);
s.addContributor('5', '2', 3); expect(pk.addContributor).toHaveBeenCalledWith('5', '2', 3);
s.removeContributor('5', '2', 3); expect(pk.removeContributor).toHaveBeenCalledWith('5', '2', 3);
s.cloneItem('5', '2', 7); expect(pk.cloneItem).toHaveBeenCalledWith('5', '2', 7);
});
describe('viewersOf + broadcastToViewers (#858 three-tier)', () => {
it('viewersOf: Common → null (whole room); restricted → owner + recipients', () => {
expect(svc().viewersOf({ is_private: 0, owner_id: 1 })).toBeNull();
expect(svc().viewersOf(null)).toBeNull();
expect(svc().viewersOf({ is_private: 1, owner_id: 1, recipients: [{ user_id: 2 }, { user_id: 3 }] })).toEqual([1, 2, 3]);
});
it('broadcastToViewers delivers to each viewer (deduped) via onlyUserId', () => {
svc().broadcastToViewers('5', 'packing:created', { item: 1 }, [1, 2, 2], 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: 1 }, 'sock', 1);
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: 1 }, 'sock', 2);
expect(broadcast).toHaveBeenCalledTimes(2);
});
});
describe('notifyTagged', () => {
@@ -46,6 +46,10 @@ import {
updateItem,
deleteItem,
listItems,
setItemSharing,
addContributor,
removeContributor,
cloneItem,
} from '../../../src/services/packingService';
// ── Lifecycle ─────────────────────────────────────────────────────────────────
@@ -378,3 +382,98 @@ describe('private items (#858)', () => {
expect(rows.find(r => r.name === 'A').is_private).toBe(0);
});
});
// ── Three-tier sharing (#858 follow-up) ───────────────────────────────────────
describe('three-tier packing sharing (#858)', () => {
const names = (rows: any[]) => rows.map(r => r.name).sort();
it('PACK-SVC-040: existing/common items are visible to everyone (non-breaking)', () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
// A legacy-style row written directly (is_private defaults 0) = Common.
testDb.prepare('INSERT INTO packing_items (trip_id, name, checked, sort_order) VALUES (?, ?, 0, 0)').run(trip.id, 'Tent');
createItem(trip.id, { name: 'Stove', visibility: 'common' }, owner.id);
expect(names(listItems(trip.id, owner.id) as any[])).toEqual(['Stove', 'Tent']);
expect(names(listItems(trip.id, other.id) as any[])).toEqual(['Stove', 'Tent']);
});
it('PACK-SVC-041: a Shared item is visible to its owner + recipients only, marked with the bringer', () => {
const { user: owner } = createUser(testDb);
const { user: friend } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const item = createItem(trip.id, { name: 'Power bank', visibility: 'shared', recipient_ids: [friend.id] }, owner.id) as any;
expect(item.is_private).toBe(1);
expect(item.owner_username).toBe(owner.username);
expect(item.recipients.map((r: any) => r.user_id)).toEqual([friend.id]);
expect(names(listItems(trip.id, owner.id) as any[])).toEqual(['Power bank']); // bringer
expect(names(listItems(trip.id, friend.id) as any[])).toEqual(['Power bank']); // covered person
expect(names(listItems(trip.id, stranger.id) as any[])).toEqual([]); // nobody else
});
it('PACK-SVC-042: a Personal item is visible only to its owner', () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
createItem(trip.id, { name: 'Diary', visibility: 'personal' }, owner.id);
expect(names(listItems(trip.id, owner.id) as any[])).toEqual(['Diary']);
expect(names(listItems(trip.id, other.id) as any[])).toEqual([]);
});
it('PACK-SVC-043: setItemSharing changes the tier + recipients; only the owner may', () => {
const { user: owner } = createUser(testDb);
const { user: friend } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const item = createItem(trip.id, { name: 'First aid', visibility: 'personal' }, owner.id) as any;
// A non-owner is rejected.
expect((setItemSharing(trip.id, item.id, friend.id, 'shared', [friend.id]) as any).forbidden).toBe(true);
const updated = setItemSharing(trip.id, item.id, owner.id, 'shared', [friend.id]) as any;
expect(updated.recipients.map((r: any) => r.user_id)).toEqual([friend.id]);
expect(names(listItems(trip.id, friend.id) as any[])).toEqual(['First aid']);
// Back to common → visible to everyone, recipients cleared.
setItemSharing(trip.id, item.id, owner.id, 'common', []);
const { user: stranger } = createUser(testDb);
expect(names(listItems(trip.id, stranger.id) as any[])).toEqual(['First aid']);
});
it('PACK-SVC-044: contributors ("I can bring that too") only attach to Common items', () => {
const { user: owner } = createUser(testDb);
const { user: helper } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const common = createItem(trip.id, { name: 'Sunscreen', visibility: 'common' }, owner.id) as any;
const personal = createItem(trip.id, { name: 'Meds', visibility: 'personal' }, owner.id) as any;
const withHelper = addContributor(trip.id, common.id, helper.id) as any;
expect(withHelper.contributors.map((c: any) => c.user_id)).toEqual([helper.id]);
// The bringer can't co-contribute to their own item, and personal items have no pool.
expect(addContributor(trip.id, common.id, owner.id)).toBeNull();
expect(addContributor(trip.id, personal.id, helper.id)).toBeNull();
const cleared = removeContributor(trip.id, common.id, helper.id) as any;
expect(cleared.contributors).toEqual([]);
});
it('PACK-SVC-045: cloneItem copies an item onto the cloner\'s personal list', () => {
const { user: owner } = createUser(testDb);
const { user: cloner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const common = createItem(trip.id, { name: 'Travel adapter', category: 'Electronics', visibility: 'common' }, owner.id) as any;
const clone = cloneItem(trip.id, common.id, cloner.id) as any;
expect(clone.name).toBe('Travel adapter');
expect(clone.category).toBe('Electronics');
expect(clone.is_private).toBe(1);
expect(clone.owner_id).toBe(cloner.id);
// The clone is the cloner's alone.
expect(names(listItems(trip.id, owner.id) as any[])).toEqual(['Travel adapter']); // owner sees only the common one
expect(names(listItems(trip.id, cloner.id) as any[])).toEqual(['Travel adapter', 'Travel adapter']); // common + own clone
});
});
+14
View File
@@ -182,5 +182,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': 'جعله خاصًا',
'packing.makePublic': 'مشاركة',
'packing.privateHint': 'خاص — مرئي لك فقط',
'packing.viewCommon': 'مشترك',
'packing.viewPersonal': 'قائمتي',
'packing.share': 'المشاركة',
'packing.tierCommonHint': 'في مجموعة الفريق، مرئي للجميع',
'packing.tierPersonal': 'شخصي',
'packing.tierPersonalHint': 'خاص — أنت فقط تراه',
'packing.tierShared': 'مشاركة مع…',
'packing.noOneToShare': 'لا أحد آخر في هذه الرحلة بعد',
'packing.takenCareOf': 'بواسطة {name}',
'packing.sharedWithCount': 'مشترك مع {count}',
'packing.broughtBy': 'يحضره {name}',
'packing.alsoBring': 'يمكنني إحضاره أيضًا',
'packing.alsoBringingStop': 'لن أحضره',
'packing.cloneToMine': 'نسخ إلى قائمتي',
};
export default packing;
+14
View File
@@ -182,5 +182,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': 'Tornar privado',
'packing.makePublic': 'Compartilhar',
'packing.privateHint': 'Privado — visível apenas para você',
'packing.viewCommon': 'Compartilhado',
'packing.viewPersonal': 'Minha lista',
'packing.share': 'Compartilhar',
'packing.tierCommonHint': 'No grupo, visível para todos',
'packing.tierPersonal': 'Pessoal',
'packing.tierPersonalHint': 'Privado — só você vê',
'packing.tierShared': 'Compartilhar com…',
'packing.noOneToShare': 'Ninguém mais nesta viagem ainda',
'packing.takenCareOf': 'por {name}',
'packing.sharedWithCount': 'compartilhado com {count}',
'packing.broughtBy': 'trazido por {name}',
'packing.alsoBring': 'Eu também posso levar',
'packing.alsoBringingStop': 'Não vou levar',
'packing.cloneToMine': 'Copiar para minha lista',
};
export default packing;
+14
View File
@@ -183,5 +183,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': 'Nastavit jako soukromé',
'packing.makePublic': 'Sdílet',
'packing.privateHint': 'Soukromé — vidíte jen vy',
'packing.viewCommon': 'Sdílené',
'packing.viewPersonal': 'Můj seznam',
'packing.share': 'Sdílení',
'packing.tierCommonHint': 'Ve společném fondu, viditelné pro všechny',
'packing.tierPersonal': 'Osobní',
'packing.tierPersonalHint': 'Soukromé — vidíte jen vy',
'packing.tierShared': 'Sdílet s…',
'packing.noOneToShare': 'Zatím nikdo další na této cestě',
'packing.takenCareOf': 'od {name}',
'packing.sharedWithCount': 'sdíleno s {count}',
'packing.broughtBy': 'přináší {name}',
'packing.alsoBring': 'Můžu to vzít taky',
'packing.alsoBringingStop': 'Neberu to',
'packing.cloneToMine': 'Kopírovat do mého seznamu',
};
export default packing;
+14
View File
@@ -54,6 +54,20 @@ const packing: TranslationStrings = {
'packing.makePrivate': 'Privat machen',
'packing.makePublic': 'Teilen',
'packing.privateHint': 'Privat — nur für dich sichtbar',
'packing.viewCommon': 'Gemeinsam',
'packing.viewPersonal': 'Meine Liste',
'packing.share': 'Teilen',
'packing.tierCommonHint': 'Im Gruppen-Pool, für alle sichtbar',
'packing.tierPersonal': 'Persönlich',
'packing.tierPersonalHint': 'Privat — nur du siehst es',
'packing.tierShared': 'Mit Personen teilen…',
'packing.noOneToShare': 'Noch niemand sonst auf dieser Reise',
'packing.takenCareOf': 'von {name}',
'packing.sharedWithCount': 'geteilt mit {count}',
'packing.broughtBy': 'bringt {name}',
'packing.alsoBring': 'Bring ich auch mit',
'packing.alsoBringingStop': 'Bring ich doch nicht mit',
'packing.cloneToMine': 'In meine Liste kopieren',
'packing.confirm.clearChecked': 'Möchtest du {count} abgehakte Gegenstände wirklich entfernen?',
'packing.confirm.deleteCat': 'Möchtest du die Kategorie "{name}" mit {count} Gegenständen wirklich löschen?',
'packing.defaultCategory': 'Sonstiges',
+14
View File
@@ -54,6 +54,20 @@ const packing: TranslationStrings = {
'packing.makePrivate': 'Make private',
'packing.makePublic': 'Make shared',
'packing.privateHint': 'Private — only visible to you',
'packing.viewCommon': 'Shared',
'packing.viewPersonal': 'My list',
'packing.share': 'Sharing',
'packing.tierCommonHint': 'In the group pool, visible to everyone',
'packing.tierPersonal': 'Personal',
'packing.tierPersonalHint': 'Private — only you can see it',
'packing.tierShared': 'Shared with…',
'packing.noOneToShare': 'No one else on this trip yet',
'packing.takenCareOf': 'by {name}',
'packing.sharedWithCount': 'shared with {count}',
'packing.broughtBy': 'brought by {name}',
'packing.alsoBring': 'I can bring that too',
'packing.alsoBringingStop': "I'm not bringing it",
'packing.cloneToMine': 'Copy to my list',
'packing.confirm.clearChecked': 'Are you sure you want to remove {count} checked items?',
'packing.confirm.deleteCat': 'Are you sure you want to delete the category "{name}" with {count} items?',
'packing.defaultCategory': 'Other',
+14
View File
@@ -183,5 +183,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': 'Hacer privado',
'packing.makePublic': 'Compartir',
'packing.privateHint': 'Privado — solo visible para ti',
'packing.viewCommon': 'Compartido',
'packing.viewPersonal': 'Mi lista',
'packing.share': 'Compartir',
'packing.tierCommonHint': 'En el grupo, visible para todos',
'packing.tierPersonal': 'Personal',
'packing.tierPersonalHint': 'Privado — solo tú lo ves',
'packing.tierShared': 'Compartir con…',
'packing.noOneToShare': 'Nadie más en este viaje todavía',
'packing.takenCareOf': 'por {name}',
'packing.sharedWithCount': 'compartido con {count}',
'packing.broughtBy': 'lo trae {name}',
'packing.alsoBring': 'Yo también puedo llevarlo',
'packing.alsoBringingStop': 'No lo llevo',
'packing.cloneToMine': 'Copiar a mi lista',
};
export default packing;
+14
View File
@@ -182,5 +182,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': 'Rendre privé',
'packing.makePublic': 'Partager',
'packing.privateHint': 'Privé — visible par vous uniquement',
'packing.viewCommon': 'Partagé',
'packing.viewPersonal': 'Ma liste',
'packing.share': 'Partage',
'packing.tierCommonHint': 'Dans le groupe, visible par tous',
'packing.tierPersonal': 'Personnel',
'packing.tierPersonalHint': 'Privé — vous seul le voyez',
'packing.tierShared': 'Partager avec…',
'packing.noOneToShare': 'Personne d\'autre sur ce voyage pour l\'instant',
'packing.takenCareOf': 'par {name}',
'packing.sharedWithCount': 'partagé avec {count}',
'packing.broughtBy': 'apporté par {name}',
'packing.alsoBring': 'Je peux l\'apporter aussi',
'packing.alsoBringingStop': 'Je ne l\'apporte pas',
'packing.cloneToMine': 'Copier dans ma liste',
};
export default packing;
+14
View File
@@ -93,5 +93,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': 'Ορισμός ως ιδιωτικό',
'packing.makePublic': 'Κοινή χρήση',
'packing.privateHint': 'Ιδιωτικό — ορατό μόνο σε εσάς',
'packing.viewCommon': 'Κοινό',
'packing.viewPersonal': 'Η λίστα μου',
'packing.share': 'Κοινή χρήση',
'packing.tierCommonHint': 'Στην ομάδα, ορατό σε όλους',
'packing.tierPersonal': 'Προσωπικό',
'packing.tierPersonalHint': 'Ιδιωτικό — μόνο εσείς το βλέπετε',
'packing.tierShared': 'Κοινή χρήση με…',
'packing.noOneToShare': 'Κανείς άλλος σε αυτό το ταξίδι ακόμη',
'packing.takenCareOf': 'από {name}',
'packing.sharedWithCount': 'κοινό με {count}',
'packing.broughtBy': 'φέρνει ο/η {name}',
'packing.alsoBring': 'Μπορώ να το φέρω κι εγώ',
'packing.alsoBringingStop': 'Δεν το φέρνω',
'packing.cloneToMine': 'Αντιγραφή στη λίστα μου',
};
export default packing;
+14
View File
@@ -183,5 +183,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': 'Priváttá tétel',
'packing.makePublic': 'Megosztás',
'packing.privateHint': 'Privát — csak Ön látja',
'packing.viewCommon': 'Megosztott',
'packing.viewPersonal': 'Saját lista',
'packing.share': 'Megosztás',
'packing.tierCommonHint': 'A csoportban, mindenki láthatja',
'packing.tierPersonal': 'Személyes',
'packing.tierPersonalHint': 'Privát — csak Ön látja',
'packing.tierShared': 'Megosztás vele…',
'packing.noOneToShare': 'Még senki más ezen az úton',
'packing.takenCareOf': '{name} hozza',
'packing.sharedWithCount': 'megosztva {count} fővel',
'packing.broughtBy': 'hozza: {name}',
'packing.alsoBring': 'Én is tudom hozni',
'packing.alsoBringingStop': 'Mégsem hozom',
'packing.cloneToMine': 'Másolás a listámra',
};
export default packing;
+14
View File
@@ -183,5 +183,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': 'Jadikan pribadi',
'packing.makePublic': 'Bagikan',
'packing.privateHint': 'Pribadi — hanya terlihat oleh Anda',
'packing.viewCommon': 'Bersama',
'packing.viewPersonal': 'Daftar saya',
'packing.share': 'Berbagi',
'packing.tierCommonHint': 'Di grup, terlihat oleh semua',
'packing.tierPersonal': 'Pribadi',
'packing.tierPersonalHint': 'Privat — hanya Anda yang melihat',
'packing.tierShared': 'Bagikan dengan…',
'packing.noOneToShare': 'Belum ada orang lain di perjalanan ini',
'packing.takenCareOf': 'oleh {name}',
'packing.sharedWithCount': 'dibagikan dengan {count}',
'packing.broughtBy': 'dibawa oleh {name}',
'packing.alsoBring': 'Saya juga bisa membawanya',
'packing.alsoBringingStop': 'Saya tidak membawanya',
'packing.cloneToMine': 'Salin ke daftar saya',
};
export default packing;
+14
View File
@@ -183,5 +183,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': 'Rendi privato',
'packing.makePublic': 'Condividi',
'packing.privateHint': 'Privato — visibile solo a te',
'packing.viewCommon': 'Condiviso',
'packing.viewPersonal': 'La mia lista',
'packing.share': 'Condivisione',
'packing.tierCommonHint': 'Nel gruppo, visibile a tutti',
'packing.tierPersonal': 'Personale',
'packing.tierPersonalHint': 'Privato — lo vedi solo tu',
'packing.tierShared': 'Condividi con…',
'packing.noOneToShare': 'Ancora nessun altro in questo viaggio',
'packing.takenCareOf': 'da {name}',
'packing.sharedWithCount': 'condiviso con {count}',
'packing.broughtBy': 'lo porta {name}',
'packing.alsoBring': 'Posso portarlo anch\'io',
'packing.alsoBringingStop': 'Non lo porto',
'packing.cloneToMine': 'Copia nella mia lista',
};
export default packing;
+14
View File
@@ -182,5 +182,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': '非公開にする',
'packing.makePublic': '共有する',
'packing.privateHint': '非公開 — 自分のみ表示',
'packing.viewCommon': 'みんなで',
'packing.viewPersonal': 'マイリスト',
'packing.share': '共有',
'packing.tierCommonHint': 'グループの共有プール、全員に表示',
'packing.tierPersonal': '個人',
'packing.tierPersonalHint': '非公開 — 自分だけ',
'packing.tierShared': '共有する相手…',
'packing.noOneToShare': 'この旅行にはまだ他に誰もいません',
'packing.takenCareOf': '{name} さん担当',
'packing.sharedWithCount': '{count} 人と共有',
'packing.broughtBy': '{name} さんが持参',
'packing.alsoBring': '私も持っていけます',
'packing.alsoBringingStop': 'やっぱり持っていきません',
'packing.cloneToMine': 'マイリストにコピー',
};
export default packing;
+14
View File
@@ -182,5 +182,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': '비공개로 설정',
'packing.makePublic': '공유',
'packing.privateHint': '비공개 — 나만 볼 수 있음',
'packing.viewCommon': '공동',
'packing.viewPersonal': '내 목록',
'packing.share': '공유',
'packing.tierCommonHint': '그룹 공용, 모두에게 표시',
'packing.tierPersonal': '개인',
'packing.tierPersonalHint': '비공개 — 나만 볼 수 있음',
'packing.tierShared': '공유 대상…',
'packing.noOneToShare': '이 여행에 아직 다른 사람이 없습니다',
'packing.takenCareOf': '{name} 담당',
'packing.sharedWithCount': '{count}명과 공유',
'packing.broughtBy': '{name}님이 가져옴',
'packing.alsoBring': '저도 가져갈 수 있어요',
'packing.alsoBringingStop': '안 가져갈게요',
'packing.cloneToMine': '내 목록으로 복사',
};
export default packing;
+14
View File
@@ -182,5 +182,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': 'Privé maken',
'packing.makePublic': 'Delen',
'packing.privateHint': 'Privé — alleen zichtbaar voor jou',
'packing.viewCommon': 'Gedeeld',
'packing.viewPersonal': 'Mijn lijst',
'packing.share': 'Delen',
'packing.tierCommonHint': 'In de groep, voor iedereen zichtbaar',
'packing.tierPersonal': 'Persoonlijk',
'packing.tierPersonalHint': 'Privé — alleen jij ziet het',
'packing.tierShared': 'Delen met…',
'packing.noOneToShare': 'Nog niemand anders op deze reis',
'packing.takenCareOf': 'door {name}',
'packing.sharedWithCount': 'gedeeld met {count}',
'packing.broughtBy': 'meegenomen door {name}',
'packing.alsoBring': 'Ik kan het ook meenemen',
'packing.alsoBringingStop': 'Ik neem het niet mee',
'packing.cloneToMine': 'Kopiëren naar mijn lijst',
};
export default packing;
+14
View File
@@ -183,5 +183,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': 'Ustaw jako prywatne',
'packing.makePublic': 'Udostępnij',
'packing.privateHint': 'Prywatne — widoczne tylko dla Ciebie',
'packing.viewCommon': 'Wspólne',
'packing.viewPersonal': 'Moja lista',
'packing.share': 'Udostępnianie',
'packing.tierCommonHint': 'W puli grupy, widoczne dla wszystkich',
'packing.tierPersonal': 'Osobiste',
'packing.tierPersonalHint': 'Prywatne — widzisz tylko Ty',
'packing.tierShared': 'Udostępnij osobom…',
'packing.noOneToShare': 'Nikogo innego na tej podróży',
'packing.takenCareOf': 'od {name}',
'packing.sharedWithCount': 'udostępniono {count} osobom',
'packing.broughtBy': 'przynosi {name}',
'packing.alsoBring': 'Ja też mogę to wziąć',
'packing.alsoBringingStop': 'Jednak nie biorę',
'packing.cloneToMine': 'Kopiuj do mojej listy',
};
export default packing;
+14
View File
@@ -182,5 +182,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': 'Сделать личным',
'packing.makePublic': 'Сделать общим',
'packing.privateHint': 'Личное — видно только вам',
'packing.viewCommon': 'Общий',
'packing.viewPersonal': 'Мой список',
'packing.share': 'Доступ',
'packing.tierCommonHint': 'В общем пуле, виден всем',
'packing.tierPersonal': 'Личный',
'packing.tierPersonalHint': 'Личное — видите только вы',
'packing.tierShared': 'Поделиться с…',
'packing.noOneToShare': 'Пока больше никого в этой поездке',
'packing.takenCareOf': 'от {name}',
'packing.sharedWithCount': 'доступно {count}',
'packing.broughtBy': 'приносит {name}',
'packing.alsoBring': 'Я тоже могу принести',
'packing.alsoBringingStop': 'Я не принесу',
'packing.cloneToMine': 'Копировать в мой список',
};
export default packing;
+14
View File
@@ -183,5 +183,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': 'Gör privat',
'packing.makePublic': 'Dela',
'packing.privateHint': 'Privat — endast synligt för dig',
'packing.viewCommon': 'Delat',
'packing.viewPersonal': 'Min lista',
'packing.share': 'Delning',
'packing.tierCommonHint': 'I grupp-poolen, synligt för alla',
'packing.tierPersonal': 'Personlig',
'packing.tierPersonalHint': 'Privat — bara du ser det',
'packing.tierShared': 'Dela med…',
'packing.noOneToShare': 'Ingen annan på den här resan ännu',
'packing.takenCareOf': 'av {name}',
'packing.sharedWithCount': 'delat med {count}',
'packing.broughtBy': 'tas med av {name}',
'packing.alsoBring': 'Jag kan ta med det också',
'packing.alsoBringingStop': 'Jag tar inte med det',
'packing.cloneToMine': 'Kopiera till min lista',
};
export default packing;
+14
View File
@@ -93,5 +93,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': 'Gizli yap',
'packing.makePublic': 'Paylaş',
'packing.privateHint': 'Gizli — yalnızca siz görebilirsiniz',
'packing.viewCommon': 'Ortak',
'packing.viewPersonal': 'Listem',
'packing.share': 'Paylaşım',
'packing.tierCommonHint': 'Grup havuzunda, herkese görünür',
'packing.tierPersonal': 'Kişisel',
'packing.tierPersonalHint': 'Özel — yalnızca siz görürsünüz',
'packing.tierShared': 'Şu kişilerle paylaş…',
'packing.noOneToShare': 'Bu seyahatte henüz başka kimse yok',
'packing.takenCareOf': '{name} getiriyor',
'packing.sharedWithCount': '{count} kişiyle paylaşıldı',
'packing.broughtBy': '{name} getiriyor',
'packing.alsoBring': 'Ben de getirebilirim',
'packing.alsoBringingStop': 'Getirmiyorum',
'packing.cloneToMine': 'Listeme kopyala',
};
export default packing;
+14
View File
@@ -182,5 +182,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': 'Зробити приватним',
'packing.makePublic': 'Зробити спільним',
'packing.privateHint': 'Приватне — бачите лише ви',
'packing.viewCommon': 'Спільне',
'packing.viewPersonal': 'Мій список',
'packing.share': 'Доступ',
'packing.tierCommonHint': 'У спільному пулі, видно всім',
'packing.tierPersonal': 'Особисте',
'packing.tierPersonalHint': 'Приватне — бачите лише ви',
'packing.tierShared': 'Поділитися з…',
'packing.noOneToShare': 'Поки більше нікого в цій подорожі',
'packing.takenCareOf': 'від {name}',
'packing.sharedWithCount': 'надано {count}',
'packing.broughtBy': 'приносить {name}',
'packing.alsoBring': 'Я теж можу принести',
'packing.alsoBringingStop': 'Я не принесу',
'packing.cloneToMine': 'Копіювати до мого списку',
};
export default packing;
+14
View File
@@ -183,5 +183,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': 'Đặt riêng tư',
'packing.makePublic': 'Chia sẻ',
'packing.privateHint': 'Riêng tư — chỉ mình bạn thấy',
'packing.viewCommon': 'Chung',
'packing.viewPersonal': 'Danh sách của tôi',
'packing.share': 'Chia sẻ',
'packing.tierCommonHint': 'Trong nhóm, mọi người đều thấy',
'packing.tierPersonal': 'Cá nhân',
'packing.tierPersonalHint': 'Riêng tư — chỉ bạn thấy',
'packing.tierShared': 'Chia sẻ với…',
'packing.noOneToShare': 'Chưa có ai khác trong chuyến đi này',
'packing.takenCareOf': 'bởi {name}',
'packing.sharedWithCount': 'chia sẻ với {count}',
'packing.broughtBy': '{name} mang',
'packing.alsoBring': 'Tôi cũng có thể mang',
'packing.alsoBringingStop': 'Tôi không mang',
'packing.cloneToMine': 'Sao chép vào danh sách của tôi',
};
export default packing;
+14
View File
@@ -181,5 +181,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': '設為私密',
'packing.makePublic': '共用',
'packing.privateHint': '私密 — 僅你可見',
'packing.viewCommon': '共用',
'packing.viewPersonal': '我的清單',
'packing.share': '共用',
'packing.tierCommonHint': '在群組共用區,所有人可見',
'packing.tierPersonal': '個人',
'packing.tierPersonalHint': '私密 — 僅你可見',
'packing.tierShared': '共用給…',
'packing.noOneToShare': '此行程中尚無其他人',
'packing.takenCareOf': '由 {name} 負責',
'packing.sharedWithCount': '已共用給 {count} 人',
'packing.broughtBy': '由 {name} 帶',
'packing.alsoBring': '我也可以帶',
'packing.alsoBringingStop': '我不帶了',
'packing.cloneToMine': '複製到我的清單',
};
export default packing;
+14
View File
@@ -181,5 +181,19 @@ const packing: TranslationStrings = {
'packing.makePrivate': '设为私密',
'packing.makePublic': '共享',
'packing.privateHint': '私密 — 仅你可见',
'packing.viewCommon': '共享',
'packing.viewPersonal': '我的清单',
'packing.share': '共享',
'packing.tierCommonHint': '在小组共享池中,所有人可见',
'packing.tierPersonal': '个人',
'packing.tierPersonalHint': '私密 — 仅你可见',
'packing.tierShared': '共享给…',
'packing.noOneToShare': '此行程中暂无其他人',
'packing.takenCareOf': '由 {name} 负责',
'packing.sharedWithCount': '已共享给 {count} 人',
'packing.broughtBy': '由 {name} 带',
'packing.alsoBring': '我也可以带',
'packing.alsoBringingStop': '我不带了',
'packing.cloneToMine': '复制到我的清单',
};
export default packing;
+25 -4
View File
@@ -29,11 +29,16 @@ export const packingItemSchema = z.object({
weight_grams: z.number().nullable().optional(),
bag_id: z.number().nullable().optional(),
quantity: z.number().optional(),
// Private items (#858): is_private is the raw SQLite INTEGER (0/1); owner_id is
// the user the item belongs to (NULL on legacy shared rows). The listing only
// returns another member's item when is_private is 0.
// 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.
@@ -71,15 +76,31 @@ export const packingBagSchema = z.object({
});
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(),
checked: z.boolean().optional(),
// Mark the new item private to its creator (#858).
// 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(),
checked: z.boolean().optional(),