mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Add drag-to-reorder to the packing and to-do lists, mirroring the budget panel's native HTML5 drag pattern. A drag within a filtered/grouped view is mapped back onto the global order so untouched items keep their place, and the order persists optimistically via the existing reorder endpoints. Packing items can now be marked private (#858): a private item is visible only to its owner. createItem/bulkImport stamp the owner, listItems filters by the viewer, and the WebSocket broadcasts are scoped to the owner so a private item never reaches another member's screen — including the public/private toggle transitions. Owners get a lock toggle and a private indicator on their items.
This commit is contained in:
@@ -27,10 +27,34 @@ interface KategorieGruppeProps {
|
||||
bags?: PackingBag[]
|
||||
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||
canEdit?: boolean
|
||||
// Drag-to-reorder (#969): the full ordered item list + a persist callback. The
|
||||
// order is global, so a within-category drag is mapped back onto the full list.
|
||||
allItems: PackingItem[]
|
||||
onReorder: (orderedIds: number[]) => void
|
||||
}
|
||||
|
||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
|
||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder }: KategorieGruppeProps) {
|
||||
const [offen, setOffen] = useState(true)
|
||||
const [dragId, setDragId] = useState<number | null>(null)
|
||||
const [overId, setOverId] = useState<number | null>(null)
|
||||
|
||||
const handleReorderDrop = (targetId: number) => {
|
||||
const from = dragId
|
||||
setDragId(null); setOverId(null)
|
||||
if (from == null || from === targetId) return
|
||||
const catOrder = items.map(i => i.id)
|
||||
const fi = catOrder.indexOf(from)
|
||||
const ti = catOrder.indexOf(targetId)
|
||||
if (fi < 0 || ti < 0) return
|
||||
catOrder.splice(fi, 1)
|
||||
catOrder.splice(ti, 0, from)
|
||||
// Slot the reordered category ids back into the positions this category's
|
||||
// items occupy in the global list, leaving every other category untouched.
|
||||
const catIds = new Set(items.map(i => i.id))
|
||||
let ci = 0
|
||||
const globalIds = allItems.map(i => (catIds.has(i.id) ? catOrder[ci++] : i.id))
|
||||
onReorder(globalIds)
|
||||
}
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [editKatName, setEditKatName] = useState(kategorie)
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
@@ -232,7 +256,15 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
{offen && (
|
||||
<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} />
|
||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit}
|
||||
drag={canEdit ? {
|
||||
isDragging: dragId === item.id,
|
||||
isOver: overId === item.id && dragId !== null && dragId !== item.id,
|
||||
onStart: (id) => { setDragId(id); setOverId(null) },
|
||||
onOver: (id) => setOverId(id),
|
||||
onEnd: () => { setDragId(null); setOverId(null) },
|
||||
onDrop: handleReorderDrop,
|
||||
} : undefined} />
|
||||
))}
|
||||
{/* Inline add item */}
|
||||
{canEdit && (showAddItem ? (
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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,
|
||||
CheckSquare, Square, Trash2, Plus, Pencil, Package, GripVertical, Lock, Unlock,
|
||||
} from 'lucide-react'
|
||||
import type { PackingItem, PackingBag } from '../../types'
|
||||
import { katColor } from './packingListPanel.helpers'
|
||||
@@ -20,9 +21,18 @@ interface ArtikelZeileProps {
|
||||
bags?: PackingBag[]
|
||||
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||
canEdit?: boolean
|
||||
// Drag-to-reorder (#969) — wired by the category group, which owns the order.
|
||||
drag?: {
|
||||
isDragging: boolean
|
||||
isOver: boolean
|
||||
onStart: (id: number) => void
|
||||
onOver: (id: number) => void
|
||||
onEnd: () => void
|
||||
onDrop: (targetId: number) => void
|
||||
}
|
||||
}
|
||||
|
||||
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
|
||||
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, drag }: ArtikelZeileProps) {
|
||||
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
|
||||
@@ -32,11 +42,23 @@ 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
|
||||
|
||||
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) }
|
||||
@@ -58,18 +80,36 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
|
||||
catch { toast.error(t('common.error')) }
|
||||
}
|
||||
|
||||
const canDrag = canEdit && !isPlaceholder && !!drag
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => { setHovered(false); setShowCatPicker(false); setShowBagPicker(false) }}
|
||||
onDragOver={canDrag ? (e => { if (drag!.isDragging || true) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag!.onOver(item.id) } }) : undefined}
|
||||
onDragLeave={canDrag ? (e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) drag!.onOver(-1) }) : undefined}
|
||||
onDrop={canDrag ? (e => { e.preventDefault(); e.stopPropagation(); drag!.onDrop(item.id) }) : undefined}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 10px', borderRadius: 10, position: 'relative',
|
||||
background: hovered ? 'var(--bg-secondary)' : 'transparent',
|
||||
transition: 'background 0.1s',
|
||||
opacity: drag?.isDragging ? 0.4 : 1,
|
||||
boxShadow: drag?.isOver ? 'inset 3px 0 0 0 var(--accent)' : 'none',
|
||||
transition: 'background 0.1s, opacity 0.15s',
|
||||
}}
|
||||
>
|
||||
{canDrag && (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; drag!.onStart(item.id) }}
|
||||
onDragEnd={() => drag!.onEnd()}
|
||||
title=""
|
||||
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0, opacity: hovered ? 1 : 0.35, transition: 'opacity 0.15s' }}
|
||||
>
|
||||
<GripVertical size={13} />
|
||||
</div>
|
||||
)}
|
||||
<button onClick={handleToggle} style={{
|
||||
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, position: 'relative',
|
||||
width: 18, height: 18,
|
||||
@@ -114,6 +154,13 @@ 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} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Quantity */}
|
||||
{canEdit && <QuantityInput value={item.quantity || 1} onSave={qty => updatePackingItem(tripId, item.id, { quantity: qty })} />}
|
||||
|
||||
@@ -245,6 +292,15 @@ 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>
|
||||
)}
|
||||
|
||||
<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)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={13} />
|
||||
|
||||
@@ -6,7 +6,7 @@ export function PackingList(S: PackingState) {
|
||||
const {
|
||||
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
|
||||
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
|
||||
bagTrackingEnabled, bags, handleCreateBagByName, canEdit,
|
||||
bagTrackingEnabled, bags, handleCreateBagByName, canEdit, reorderPackingItems,
|
||||
} = S
|
||||
return (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
|
||||
@@ -40,6 +40,8 @@ export function PackingList(S: PackingState) {
|
||||
bags={bags}
|
||||
onCreateBag={handleCreateBagByName}
|
||||
canEdit={canEdit}
|
||||
allItems={items}
|
||||
onReorder={(orderedIds) => reorderPackingItems(tripId, orderedIds)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem } = useTripStore()
|
||||
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem, reorderPackingItems } = useTripStore()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('packing_edit', trip)
|
||||
@@ -340,7 +340,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
const font = { fontFamily: "var(--font-system)" }
|
||||
|
||||
return {
|
||||
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
|
||||
tripId, items, inlineHeader, t, canEdit, isAdmin, font, reorderPackingItems,
|
||||
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
||||
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
|
||||
|
||||
@@ -22,7 +22,7 @@ import TodoRow from './TodoRow'
|
||||
export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tripId: number; items: TodoItem[]; addItemSignal?: number }) {
|
||||
// Layout component: state/effects/derived/handlers live in useTodoList.
|
||||
const {
|
||||
canEdit, t, formatDate, toggleTodoItem,
|
||||
canEdit, t, formatDate, toggleTodoItem, reorderTodoItems,
|
||||
isMobile, filter, setFilter, selectedId, setSelectedId,
|
||||
isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio,
|
||||
addingCategory, setAddingCategory, newCategoryName, setNewCategoryName,
|
||||
@@ -31,6 +31,31 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
||||
addCategory, catCount,
|
||||
} = useTodoList(tripId, items, addItemSignal)
|
||||
|
||||
// Drag-to-reorder (#969). Manual ordering only makes sense when the list isn't
|
||||
// sorted by priority; a drag within the filtered view is mapped back onto the
|
||||
// full item order so unfiltered tasks keep their place.
|
||||
const [dragId, setDragId] = useState<number | null>(null)
|
||||
const [overId, setOverId] = useState<number | null>(null)
|
||||
const canReorder = canEdit && !sortByPrio
|
||||
|
||||
const handleReorderDrop = (targetId: number) => {
|
||||
const from = dragId
|
||||
setDragId(null); setOverId(null)
|
||||
if (from == null || from === targetId) return
|
||||
const viewOrder = filtered.map(i => i.id)
|
||||
const fi = viewOrder.indexOf(from)
|
||||
const ti = viewOrder.indexOf(targetId)
|
||||
if (fi < 0 || ti < 0) return
|
||||
viewOrder.splice(fi, 1)
|
||||
viewOrder.splice(ti, 0, from)
|
||||
// Slot the reordered visible ids back into the positions they occupy in the
|
||||
// global list, leaving every filtered-out task where it was.
|
||||
const viewIds = new Set(filtered.map(i => i.id))
|
||||
let vi = 0
|
||||
const globalIds = items.map(i => (viewIds.has(i.id) ? viewOrder[vi++] : i.id))
|
||||
reorderTodoItems(tripId, globalIds)
|
||||
}
|
||||
|
||||
// Sidebar filter item
|
||||
const SidebarItem = ({ id, icon: Icon, label, count, color }: { id: string; icon: any; label: string; count: number; color?: string }) => (
|
||||
<button onClick={() => setFilter(id as FilterType)}
|
||||
@@ -189,6 +214,14 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
||||
formatDate={formatDate}
|
||||
onSelect={(id) => { setSelectedId(id); setIsAddingNew(false) }}
|
||||
onToggle={(id, checked) => toggleTodoItem(tripId, id, checked)}
|
||||
drag={canReorder ? {
|
||||
isDragging: dragId === item.id,
|
||||
isOver: overId === item.id && dragId !== null && dragId !== item.id,
|
||||
onStart: (id) => { setDragId(id); setOverId(null) },
|
||||
onOver: (id) => setOverId(id),
|
||||
onEnd: () => { setDragId(null); setOverId(null) },
|
||||
onDrop: handleReorderDrop,
|
||||
} : undefined}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { CheckSquare, Square, ChevronRight, Flag, Calendar } from 'lucide-react'
|
||||
import { CheckSquare, Square, ChevronRight, Flag, Calendar, GripVertical } from 'lucide-react'
|
||||
import type { TodoItem } from '../../types'
|
||||
import { katColor, PRIO_CONFIG, type Member } from './todoListModel'
|
||||
|
||||
/** A single task row in the todo list. Pure presentation; all behaviour is
|
||||
* delegated to onSelect/onToggle so TodoListPanel stays a layout component. */
|
||||
export default function TodoRow({ item, members, categories, today, isSelected, canEdit, formatDate, onSelect, onToggle }: {
|
||||
export default function TodoRow({ item, members, categories, today, isSelected, canEdit, formatDate, onSelect, onToggle, drag }: {
|
||||
item: TodoItem
|
||||
members: Member[]
|
||||
categories: string[]
|
||||
@@ -14,24 +14,51 @@ export default function TodoRow({ item, members, categories, today, isSelected,
|
||||
formatDate: (d: string) => string
|
||||
onSelect: (id: number | null) => void
|
||||
onToggle: (id: number, checked: boolean) => void
|
||||
// Drag-to-reorder (#969); only provided when manual ordering is active.
|
||||
drag?: {
|
||||
isDragging: boolean
|
||||
isOver: boolean
|
||||
onStart: (id: number) => void
|
||||
onOver: (id: number) => void
|
||||
onEnd: () => void
|
||||
onDrop: (targetId: number) => void
|
||||
}
|
||||
}) {
|
||||
const done = !!item.checked
|
||||
const assignedUser = members.find(m => m.id === item.assigned_user_id)
|
||||
const isOverdue = item.due_date && !done && item.due_date < today
|
||||
const catColor = item.category ? katColor(item.category, categories) : null
|
||||
const canDrag = canEdit && !!drag
|
||||
|
||||
return (
|
||||
<div key={item.id}
|
||||
onClick={() => onSelect(isSelected ? null : item.id)}
|
||||
onDragOver={canDrag ? (e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag!.onOver(item.id) }) : undefined}
|
||||
onDragLeave={canDrag ? (e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) drag!.onOver(-1) }) : undefined}
|
||||
onDrop={canDrag ? (e => { e.preventDefault(); e.stopPropagation(); drag!.onDrop(item.id) }) : undefined}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px',
|
||||
borderBottom: '1px solid var(--border-faint)', cursor: 'pointer',
|
||||
background: isSelected ? 'var(--bg-hover)' : 'transparent',
|
||||
transition: 'background 0.1s',
|
||||
opacity: drag?.isDragging ? 0.4 : 1,
|
||||
boxShadow: drag?.isOver ? 'inset 3px 0 0 0 var(--accent)' : 'none',
|
||||
transition: 'background 0.1s, opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(0,0,0,0.02)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}>
|
||||
|
||||
{canDrag && (
|
||||
<div
|
||||
draggable
|
||||
onClick={e => e.stopPropagation()}
|
||||
onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; drag!.onStart(item.id) }}
|
||||
onDragEnd={() => drag!.onEnd()}
|
||||
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0, marginLeft: -6 }}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Checkbox */}
|
||||
<button onClick={e => { e.stopPropagation(); if (canEdit) onToggle(item.id, !done) }}
|
||||
style={{ background: 'none', border: 'none', cursor: canEdit ? 'pointer' : 'default', padding: 0, flexShrink: 0,
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { FilterType, Member } from './todoListModel'
|
||||
* (TodoRow) and the detail/new panes from this state.
|
||||
*/
|
||||
export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: number) {
|
||||
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore()
|
||||
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem, reorderTodoItems } = useTripStore()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const can = useCanDo()
|
||||
const canEdit = can('packing_edit', trip)
|
||||
@@ -100,7 +100,7 @@ export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: nu
|
||||
const catCount = (cat: string) => items.filter(i => i.category === cat && !i.checked).length
|
||||
|
||||
return {
|
||||
canEdit, t, formatDate, toggleTodoItem,
|
||||
canEdit, t, formatDate, toggleTodoItem, reorderTodoItems,
|
||||
isMobile, filter, setFilter, selectedId, setSelectedId,
|
||||
isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio,
|
||||
addingCategory, setAddingCategory, newCategoryName, setNewCategoryName,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// FE-STORE-PACKING-001 to FE-STORE-PACKING-002 (reorder, #969)
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildPackingItem } from '../../../tests/helpers/factories';
|
||||
import { useTripStore } from '../tripStore';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('packingSlice', () => {
|
||||
it('FE-STORE-PACKING-001: reorderPackingItems reorders optimistically and reindexes sort_order', async () => {
|
||||
const a = buildPackingItem({ id: 1, trip_id: 1, sort_order: 0 });
|
||||
const b = buildPackingItem({ id: 2, trip_id: 1, sort_order: 1 });
|
||||
seedStore(useTripStore, { packingItems: [a, b] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/reorder', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
await useTripStore.getState().reorderPackingItems(1, [2, 1]);
|
||||
const items = useTripStore.getState().packingItems;
|
||||
expect(items[0].id).toBe(2);
|
||||
expect(items[0].sort_order).toBe(0);
|
||||
expect(items[1].id).toBe(1);
|
||||
expect(items[1].sort_order).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-STORE-PACKING-002: reorderPackingItems rolls back to previous order on API error', async () => {
|
||||
const a = buildPackingItem({ id: 1, trip_id: 1, sort_order: 0 });
|
||||
const b = buildPackingItem({ id: 2, trip_id: 1, sort_order: 1 });
|
||||
seedStore(useTripStore, { packingItems: [a, b] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/packing/reorder', () =>
|
||||
HttpResponse.json({ error: 'error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
await useTripStore.getState().reorderPackingItems(1, [2, 1]);
|
||||
// After failure the original order is restored
|
||||
const items = useTripStore.getState().packingItems;
|
||||
expect(items[0].id).toBe(1);
|
||||
expect(items[1].id).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { packingRepo } from '../../repo/packingRepo'
|
||||
import { packingApi } from '../../api/client'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { PackingItem } from '../../types'
|
||||
@@ -13,6 +14,7 @@ export interface PackingSlice {
|
||||
updatePackingItem: (tripId: number | string, id: number, data: Partial<PackingItem>) => Promise<PackingItem>
|
||||
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>
|
||||
}
|
||||
|
||||
export const createPackingSlice = (set: SetState, get: GetState): PackingSlice => ({
|
||||
@@ -68,4 +70,24 @@ export const createPackingSlice = (set: SetState, get: GetState): PackingSlice =
|
||||
notify(getApiErrorMessage(err, 'Error updating item'), 'error')
|
||||
}
|
||||
},
|
||||
|
||||
reorderPackingItems: async (tripId, orderedIds) => {
|
||||
const prev = get().packingItems
|
||||
// Optimistic reorder: rebuild the array in the requested order, reindexing
|
||||
// sort_order; any items not in orderedIds keep their place at the end.
|
||||
set(state => {
|
||||
const byId = new Map(state.packingItems.map(i => [i.id, i]))
|
||||
const reordered = orderedIds
|
||||
.map((id, idx): PackingItem | null => { const item = byId.get(id); return item ? { ...item, sort_order: idx } : null })
|
||||
.filter((i): i is PackingItem => i !== null)
|
||||
const remaining = state.packingItems.filter(i => !orderedIds.includes(i.id))
|
||||
return { packingItems: [...reordered, ...remaining] }
|
||||
})
|
||||
try {
|
||||
await packingApi.reorder(tripId, orderedIds)
|
||||
} catch (err: unknown) {
|
||||
set({ packingItems: prev })
|
||||
notify(getApiErrorMessage(err, 'Error reordering items'), 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// FE-STORE-TODO-001 to FE-STORE-TODO-002 (reorder, #969)
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildTodoItem } from '../../../tests/helpers/factories';
|
||||
import { useTripStore } from '../tripStore';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('todoSlice', () => {
|
||||
it('FE-STORE-TODO-001: reorderTodoItems reorders optimistically and reindexes sort_order', async () => {
|
||||
const a = buildTodoItem({ id: 1, trip_id: 1, sort_order: 0 });
|
||||
const b = buildTodoItem({ id: 2, trip_id: 1, sort_order: 1 });
|
||||
seedStore(useTripStore, { todoItems: [a, b] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/todo/reorder', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
await useTripStore.getState().reorderTodoItems(1, [2, 1]);
|
||||
const items = useTripStore.getState().todoItems;
|
||||
expect(items[0].id).toBe(2);
|
||||
expect(items[0].sort_order).toBe(0);
|
||||
expect(items[1].id).toBe(1);
|
||||
expect(items[1].sort_order).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-STORE-TODO-002: reorderTodoItems rolls back to previous order on API error', async () => {
|
||||
const a = buildTodoItem({ id: 1, trip_id: 1, sort_order: 0 });
|
||||
const b = buildTodoItem({ id: 2, trip_id: 1, sort_order: 1 });
|
||||
seedStore(useTripStore, { todoItems: [a, b] });
|
||||
|
||||
server.use(
|
||||
http.put('/api/trips/1/todo/reorder', () =>
|
||||
HttpResponse.json({ error: 'error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
await useTripStore.getState().reorderTodoItems(1, [2, 1]);
|
||||
// After failure the original order is restored
|
||||
const items = useTripStore.getState().todoItems;
|
||||
expect(items[0].id).toBe(1);
|
||||
expect(items[1].id).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ export interface TodoSlice {
|
||||
updateTodoItem: (tripId: number | string, id: number, data: TodoUpdateItemRequest) => Promise<TodoItem>
|
||||
deleteTodoItem: (tripId: number | string, id: number) => Promise<void>
|
||||
toggleTodoItem: (tripId: number | string, id: number, checked: boolean) => Promise<void>
|
||||
reorderTodoItems: (tripId: number | string, orderedIds: number[]) => Promise<void>
|
||||
}
|
||||
|
||||
export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
|
||||
@@ -69,4 +70,22 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
|
||||
notify(getApiErrorMessage(err, 'Error updating todo'), 'error')
|
||||
}
|
||||
},
|
||||
|
||||
reorderTodoItems: async (tripId, orderedIds) => {
|
||||
const prev = get().todoItems
|
||||
set(state => {
|
||||
const byId = new Map(state.todoItems.map(i => [i.id, i]))
|
||||
const reordered = orderedIds
|
||||
.map((id, idx): TodoItem | null => { const item = byId.get(id); return item ? { ...item, sort_order: idx } : null })
|
||||
.filter((i): i is TodoItem => i !== null)
|
||||
const remaining = state.todoItems.filter(i => !orderedIds.includes(i.id))
|
||||
return { todoItems: [...reordered, ...remaining] }
|
||||
})
|
||||
try {
|
||||
await todoApi.reorder(tripId, orderedIds)
|
||||
} catch (err: unknown) {
|
||||
set({ todoItems: prev })
|
||||
notify(getApiErrorMessage(err, 'Error reordering todos'), 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3132,6 +3132,21 @@ function runMigrations(db: Database.Database): void {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
},
|
||||
// Private packing items (#858): an item can be hidden from other trip members.
|
||||
// is_private toggles the visibility; owner_id records who it belongs to so the
|
||||
// listing can show it only to them. owner_id is NULL on legacy rows (shared).
|
||||
() => {
|
||||
for (const stmt of [
|
||||
'ALTER TABLE packing_items ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE packing_items ADD COLUMN owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL',
|
||||
]) {
|
||||
try {
|
||||
db.exec(stmt);
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -127,7 +127,8 @@ export function registerResources(server: McpServer, userId: number, scopes: str
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const items = listPackingItems(id);
|
||||
// Hide other members' private items (#858) from the requesting user.
|
||||
const items = listPackingItems(id, userId);
|
||||
return jsonContent(uri.href, items);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -44,7 +44,7 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
const item = createPackingItem(tripId, { name, category: category || 'General' });
|
||||
const item = createPackingItem(tripId, { name, category: category || 'General' }, userId);
|
||||
safeBroadcast(tripId, 'packing:created', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
@@ -386,7 +386,7 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||
const created = bulkImport(tripId, items);
|
||||
const created = bulkImport(tripId, items, userId);
|
||||
for (const item of created) safeBroadcast(tripId, 'packing:created', { item });
|
||||
return ok({ items: created, count: created.length });
|
||||
}
|
||||
|
||||
@@ -77,7 +77,8 @@ ${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.l
|
||||
if (!canAccessTrip(tripId, userId)) {
|
||||
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] };
|
||||
}
|
||||
const items = listPackingItems(tripId);
|
||||
// Hide other members' private items (#858) from the requesting user.
|
||||
const items = listPackingItems(tripId, userId);
|
||||
if (!items.length) {
|
||||
return { messages: [{ role: 'user', content: { type: 'text', text: 'No packing items found for this trip.' } }] };
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ import { isUpdateConflict } from '../../services/conflictResult';
|
||||
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 };
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/packing — trip-scoped packing list (items, bags, templates,
|
||||
* assignees).
|
||||
@@ -51,7 +54,8 @@ export class PackingController {
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { items: this.packing.listItems(tripId) };
|
||||
// Pass the viewer so private items (#858) owned by other members are hidden.
|
||||
return { items: this.packing.listItems(tripId, user.id) };
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@@ -66,9 +70,9 @@ export class PackingController {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
throw new HttpException({ error: 'items must be a non-empty array' }, 400);
|
||||
}
|
||||
const created = this.packing.bulkImport(tripId, items);
|
||||
const created = this.packing.bulkImport(tripId, items, user.id);
|
||||
for (const item of created) {
|
||||
this.packing.broadcast(tripId, 'packing:created', { item }, socketId);
|
||||
this.packing.broadcastItem(tripId, 'packing:created', { item }, item, socketId);
|
||||
}
|
||||
return { items: created, count: created.length };
|
||||
}
|
||||
@@ -77,7 +81,7 @@ export class PackingController {
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: { name?: string; category?: string; checked?: boolean },
|
||||
@Body() body: { name?: string; category?: string; checked?: boolean; is_private?: boolean },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
@@ -85,8 +89,8 @@ 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 });
|
||||
this.packing.broadcast(tripId, 'packing:created', { item }, socketId);
|
||||
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);
|
||||
return { item };
|
||||
}
|
||||
|
||||
@@ -114,8 +118,11 @@ export class PackingController {
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const { name, checked, category, weight_grams, bag_id, quantity } = body as Record<string, never>;
|
||||
const updated = this.packing.updateItem(tripId, id, { name, checked, category, weight_grams, bag_id, quantity }, Object.keys(body), ifMatch);
|
||||
// Privacy state before the change, so a public↔private toggle (#858) can route
|
||||
// the broadcast correctly instead of leaking a freshly-privatized item.
|
||||
const before = this.packing.getItemPrivacy(tripId, id);
|
||||
const { name, checked, category, weight_grams, bag_id, quantity, is_private } = body as Record<string, never>;
|
||||
const updated = this.packing.updateItem(tripId, id, { name, checked, category, weight_grams, bag_id, quantity, is_private }, Object.keys(body), ifMatch, user.id);
|
||||
if (!updated) {
|
||||
throw new HttpException({ error: 'Item not found' }, 404);
|
||||
}
|
||||
@@ -123,10 +130,41 @@ export class PackingController {
|
||||
if (isUpdateConflict(updated)) {
|
||||
throw new HttpException({ error: 'conflict', server: updated.server }, 409);
|
||||
}
|
||||
this.packing.broadcast(tripId, 'packing:updated', { item: updated }, socketId);
|
||||
this.broadcastUpdate(tripId, id, updated as PackingItemRow, !!before?.is_private, socketId);
|
||||
return { item: updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a packing-item update over WebSocket so private items (#858) stay
|
||||
* scoped to their owner across the four public↔private transitions:
|
||||
* - stays private → owner-only update
|
||||
* - public→private → drop it from the whole room, re-add for the owner
|
||||
* - private→public → create for members who lacked it, then update for all
|
||||
* - stays public → plain update to all
|
||||
*/
|
||||
private broadcastUpdate(
|
||||
tripId: string,
|
||||
id: string,
|
||||
item: PackingItemRow,
|
||||
wasPrivate: boolean,
|
||||
socketId: string | undefined,
|
||||
): void {
|
||||
const nowPrivate = !!item.is_private;
|
||||
if (nowPrivate) {
|
||||
if (wasPrivate) {
|
||||
this.packing.broadcastItem(tripId, 'packing:updated', { item }, item, socketId);
|
||||
} else {
|
||||
this.packing.broadcast(tripId, 'packing:deleted', { itemId: Number(id) }, socketId);
|
||||
this.packing.broadcastItem(tripId, 'packing:created', { item }, item, socketId);
|
||||
}
|
||||
} else {
|
||||
if (wasPrivate) {
|
||||
this.packing.broadcast(tripId, 'packing:created', { item }, socketId);
|
||||
}
|
||||
this.packing.broadcast(tripId, 'packing:updated', { item }, socketId);
|
||||
}
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentUser() user: User,
|
||||
@@ -136,10 +174,12 @@ export class PackingController {
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.packing.deleteItem(tripId, id)) {
|
||||
const deleted = this.packing.deleteItem(tripId, id);
|
||||
if (!deleted) {
|
||||
throw new HttpException({ error: 'Item not found' }, 404);
|
||||
}
|
||||
this.packing.broadcast(tripId, 'packing:deleted', { itemId: Number(id) }, socketId);
|
||||
// Scope the delete to the owner when the item was private (#858).
|
||||
this.packing.broadcastItem(tripId, 'packing:deleted', { itemId: Number(id) }, deleted, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
import * as svc from '../../services/packingService';
|
||||
|
||||
/** Privacy fields stamped on a packing item (#858). */
|
||||
type PrivacyFields = { is_private?: number; owner_id?: number | null };
|
||||
|
||||
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
||||
|
||||
/**
|
||||
@@ -27,24 +30,40 @@ export class PackingService {
|
||||
broadcast(tripId, event, payload, socketId);
|
||||
}
|
||||
|
||||
listItems(tripId: string) {
|
||||
return svc.listItems(tripId);
|
||||
/**
|
||||
* Broadcast an item event, but keep private items (#858) off other members'
|
||||
* screens: when the item is private the event is delivered only to its owner's
|
||||
* sockets. Shared items broadcast to the whole trip room as before.
|
||||
*/
|
||||
broadcastItem(tripId: string, event: string, payload: Record<string, unknown>, item: PrivacyFields | null | undefined, socketId: string | undefined): void {
|
||||
const onlyUserId = item?.is_private && item.owner_id != null ? item.owner_id : undefined;
|
||||
broadcast(tripId, event, payload, socketId, onlyUserId);
|
||||
}
|
||||
|
||||
createItem(tripId: string, data: { name: string; category?: string; checked?: boolean }) {
|
||||
return svc.createItem(tripId, data);
|
||||
listItems(tripId: string, userId?: number) {
|
||||
return svc.listItems(tripId, userId);
|
||||
}
|
||||
|
||||
updateItem(tripId: string, id: string, data: Parameters<typeof svc.updateItem>[2], changedKeys: string[], ifMatch?: string) {
|
||||
return svc.updateItem(tripId, id, data, changedKeys, ifMatch);
|
||||
/** Reads an item's current privacy fields (#858) before an update, so the
|
||||
* controller can detect a public↔private transition and route the broadcast. */
|
||||
getItemPrivacy(tripId: string, id: string): PrivacyFields | undefined {
|
||||
return db.prepare('SELECT is_private, owner_id FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId) as PrivacyFields | undefined;
|
||||
}
|
||||
|
||||
deleteItem(tripId: string, id: string): boolean {
|
||||
createItem(tripId: string, data: { name: string; category?: string; checked?: boolean; is_private?: boolean }, ownerId?: number) {
|
||||
return svc.createItem(tripId, data, ownerId);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
deleteItem(tripId: string, id: string) {
|
||||
return svc.deleteItem(tripId, id);
|
||||
}
|
||||
|
||||
bulkImport(tripId: string, items: Parameters<typeof svc.bulkImport>[1]) {
|
||||
return svc.bulkImport(tripId, items);
|
||||
bulkImport(tripId: string, items: Parameters<typeof svc.bulkImport>[1], ownerId?: number) {
|
||||
return svc.bulkImport(tripId, items, ownerId);
|
||||
}
|
||||
|
||||
reorderItems(tripId: string, orderedIds: Parameters<typeof svc.reorderItems>[1]): void {
|
||||
|
||||
@@ -8,20 +8,28 @@ export { verifyTripAccess } from './tripAccess';
|
||||
|
||||
// ── Items ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function listItems(tripId: string | number) {
|
||||
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);
|
||||
}
|
||||
return db.prepare(
|
||||
'SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC'
|
||||
).all(tripId);
|
||||
'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);
|
||||
}
|
||||
|
||||
export function createItem(tripId: string | number, data: { name: string; category?: string; checked?: boolean; quantity?: number }) {
|
||||
export function createItem(tripId: string | number, data: { name: string; category?: string; checked?: boolean; quantity?: number; is_private?: boolean }, 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 result = db.prepare(
|
||||
'INSERT INTO packing_items (trip_id, name, checked, category, sort_order, quantity, updated_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)'
|
||||
).run(tripId, data.name, data.checked ? 1 : 0, data.category || 'Allgemein', sortOrder, qty);
|
||||
'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);
|
||||
|
||||
return db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid);
|
||||
}
|
||||
@@ -29,11 +37,12 @@ export function createItem(tripId: string | number, data: { name: string; catego
|
||||
export function updateItem(
|
||||
tripId: string | number,
|
||||
id: string | number,
|
||||
data: { name?: string; checked?: number; category?: string; weight_grams?: number | null; bag_id?: number | null; quantity?: number },
|
||||
data: { name?: string; checked?: number; category?: string; weight_grams?: number | null; bag_id?: number | null; quantity?: number; is_private?: boolean },
|
||||
bodyKeys: string[],
|
||||
ifMatch?: string,
|
||||
actingUserId?: number,
|
||||
): unknown | UpdateConflict | null {
|
||||
const item = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId) as { updated_at?: string | null } | undefined;
|
||||
const item = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId) as { updated_at?: string | null; owner_id?: number | null } | undefined;
|
||||
if (!item) return null;
|
||||
|
||||
// Optimistic concurrency (#1135): reject a stale offline overwrite. Absent
|
||||
@@ -42,6 +51,10 @@ export function updateItem(
|
||||
return { conflict: true, server: db.prepare('SELECT * FROM packing_items WHERE id = ?').get(id) };
|
||||
}
|
||||
|
||||
// Privatizing an unowned (legacy) item stamps the acting user as its owner so
|
||||
// the visibility filter still has someone to match (#858).
|
||||
const claimOwner = bodyKeys.includes('is_private') && !!data.is_private && item.owner_id == null && actingUserId != null;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE packing_items SET
|
||||
name = COALESCE(?, name),
|
||||
@@ -50,6 +63,8 @@ export function updateItem(
|
||||
weight_grams = CASE WHEN ? THEN ? ELSE weight_grams END,
|
||||
bag_id = CASE WHEN ? THEN ? ELSE bag_id END,
|
||||
quantity = CASE WHEN ? THEN ? ELSE quantity END,
|
||||
is_private = CASE WHEN ? THEN ? ELSE is_private END,
|
||||
owner_id = CASE WHEN ? THEN ? ELSE owner_id END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
@@ -63,6 +78,10 @@ export function updateItem(
|
||||
data.bag_id ?? null,
|
||||
bodyKeys.includes('quantity') ? 1 : 0,
|
||||
data.quantity ? Math.max(1, Math.min(999, Number(data.quantity))) : 1,
|
||||
bodyKeys.includes('is_private') ? 1 : 0,
|
||||
data.is_private ? 1 : 0,
|
||||
claimOwner ? 1 : 0,
|
||||
actingUserId ?? null,
|
||||
id
|
||||
);
|
||||
|
||||
@@ -70,11 +89,13 @@ export function updateItem(
|
||||
}
|
||||
|
||||
export function deleteItem(tripId: string | number, id: string | number) {
|
||||
const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!item) return false;
|
||||
// Return the deleted row (not just a boolean) so callers can target the
|
||||
// delete broadcast at the owner when the item was private (#858).
|
||||
const item = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId) as { is_private?: number; owner_id?: number | null } | undefined;
|
||||
if (!item) return null;
|
||||
|
||||
db.prepare('DELETE FROM packing_items WHERE id = ?').run(id);
|
||||
return true;
|
||||
return item;
|
||||
}
|
||||
|
||||
// ── Bulk Import ────────────────────────────────────────────────────────────
|
||||
@@ -86,13 +107,14 @@ interface ImportItem {
|
||||
weight_grams?: string | number;
|
||||
bag?: string;
|
||||
quantity?: number;
|
||||
is_private?: boolean;
|
||||
}
|
||||
|
||||
export function bulkImport(tripId: string | number, items: ImportItem[]) {
|
||||
export function bulkImport(tripId: string | number, items: ImportItem[], ownerId?: number) {
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
|
||||
let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
|
||||
const stmt = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order, quantity, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)');
|
||||
const stmt = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order, quantity, is_private, owner_id, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)');
|
||||
const created: any[] = [];
|
||||
|
||||
const insertAll = db.transaction(() => {
|
||||
@@ -116,7 +138,7 @@ export function bulkImport(tripId: string | number, items: ImportItem[]) {
|
||||
}
|
||||
|
||||
const qty = Math.max(1, Math.min(999, Number(item.quantity) || 1));
|
||||
const result = stmt.run(tripId, item.name.trim(), checked, item.category?.trim() || 'Other', weight, bagId, sortOrder++, qty);
|
||||
const result = stmt.run(tripId, item.name.trim(), checked, item.category?.trim() || 'Other', weight, bagId, sortOrder++, qty, item.is_private ? 1 : 0, ownerId ?? null);
|
||||
created.push(db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -186,8 +186,11 @@ function leaveRoom(ws: NomadWebSocket, tripId: number): void {
|
||||
|
||||
/**
|
||||
* Broadcast an event to all sockets in a trip room, optionally excluding a socket.
|
||||
* When `onlyUserId` is given the event is delivered only to that user's sockets in
|
||||
* the room — used to keep private packing items (#858) off other members' screens
|
||||
* while still syncing the owner's own tabs.
|
||||
*/
|
||||
function broadcast(tripId: number | string, eventType: string, payload: Record<string, unknown>, excludeSid?: number | string): void {
|
||||
function broadcast(tripId: number | string, eventType: string, payload: Record<string, unknown>, excludeSid?: number | string, onlyUserId?: number): void {
|
||||
tripId = Number(tripId);
|
||||
const room = rooms.get(tripId);
|
||||
if (!room || room.size === 0) return;
|
||||
@@ -198,6 +201,7 @@ function broadcast(tripId: number | string, eventType: string, payload: Record<s
|
||||
if (ws.readyState !== 1) continue; // WebSocket.OPEN === 1
|
||||
// Exclude the specific socket that triggered the change
|
||||
if (excludeNum && socketId.get(ws) === excludeNum) continue;
|
||||
if (onlyUserId != null && socketUser.get(ws)?.id !== onlyUserId) continue;
|
||||
ws.send(JSON.stringify({ type: eventType, tripId, ...payload }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,46 @@ describe('List packing items', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Private items (#858)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Private packing items (#858)', () => {
|
||||
it('PACK-PRIV-001 — a private item is hidden from other members but visible to its owner', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
// Owner creates one shared and one private item.
|
||||
await request(app).post(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(owner.id)).send({ name: 'Shared tent' });
|
||||
const priv = await request(app).post(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(owner.id)).send({ name: 'Surprise gift', is_private: true });
|
||||
expect(priv.body.item.is_private).toBe(1);
|
||||
expect(priv.body.item.owner_id).toBe(owner.id);
|
||||
|
||||
const ownerView = await request(app).get(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(owner.id));
|
||||
const memberView = await request(app).get(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(ownerView.body.items.map((i: any) => i.name).sort()).toEqual(['Shared tent', 'Surprise gift']);
|
||||
expect(memberView.body.items.map((i: any) => i.name)).toEqual(['Shared tent']);
|
||||
});
|
||||
|
||||
it('PACK-PRIV-002 — toggling an item private hides it from other members', 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: 'Diary' });
|
||||
const id = created.body.item.id;
|
||||
|
||||
await request(app).put(`/api/trips/${trip.id}/packing/${id}`).set('Cookie', authCookie(owner.id)).send({ is_private: true });
|
||||
|
||||
const memberView = await request(app).get(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(member.id));
|
||||
expect(memberView.body.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Update packing item
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -14,6 +14,8 @@ function makeService(overrides: Partial<PackingService> = {}): PackingService {
|
||||
verifyTripAccess: vi.fn().mockReturnValue(trip),
|
||||
canEdit: vi.fn().mockReturnValue(true),
|
||||
broadcast: vi.fn(),
|
||||
broadcastItem: vi.fn(),
|
||||
getItemPrivacy: vi.fn().mockReturnValue(undefined),
|
||||
notifyTagged: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as PackingService;
|
||||
@@ -58,20 +60,22 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
|
||||
});
|
||||
});
|
||||
|
||||
it('creates an item and broadcasts', () => {
|
||||
it('creates an item (owned by the creator) and broadcasts', () => {
|
||||
const createItem = vi.fn().mockReturnValue({ id: 9, name: 'Socks' });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ createItem, broadcast } as Partial<PackingService>);
|
||||
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' } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: { id: 9, name: 'Socks' } }, 'sock');
|
||||
// Stamps the creator as owner (#858) and routes the broadcast through broadcastItem.
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
it('GET / lists items for the trip (success path)', () => {
|
||||
it('GET / lists items for the trip, scoped to the viewer (#858)', () => {
|
||||
const listItems = vi.fn().mockReturnValue([{ id: 1 }, { id: 2 }]);
|
||||
const svc = makeService({ listItems } as Partial<PackingService>);
|
||||
expect(new PackingController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }, { id: 2 }] });
|
||||
expect(listItems).toHaveBeenCalledWith('5');
|
||||
expect(listItems).toHaveBeenCalledWith('5', user.id);
|
||||
});
|
||||
|
||||
describe('POST /import', () => {
|
||||
@@ -96,13 +100,14 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
|
||||
});
|
||||
});
|
||||
|
||||
it('imports and broadcasts per item', () => {
|
||||
it('imports (owned by the importer) and broadcasts per item', () => {
|
||||
const bulkImport = vi.fn().mockReturnValue([{ id: 1 }, { id: 2 }]);
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ bulkImport, broadcast } as Partial<PackingService>);
|
||||
const broadcastItem = vi.fn();
|
||||
const svc = makeService({ bulkImport, broadcastItem } as Partial<PackingService>);
|
||||
const res = new PackingController(svc).importItems(user, '5', [{ name: 'a' }, { name: 'b' }], 'sock');
|
||||
expect(res).toEqual({ items: [{ id: 1 }, { id: 2 }], count: 2 });
|
||||
expect(broadcast).toHaveBeenCalledTimes(2);
|
||||
expect(bulkImport).toHaveBeenCalledWith('5', [{ name: 'a' }, { name: 'b' }], user.id);
|
||||
expect(broadcastItem).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,15 +119,53 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
|
||||
});
|
||||
});
|
||||
|
||||
it('updates, forwards changed keys, and broadcasts', () => {
|
||||
it('updates, forwards changed keys + acting user, and broadcasts (stays public)', () => {
|
||||
const updateItem = vi.fn().mockReturnValue({ id: 9, name: 'X' });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ updateItem, broadcast } as Partial<PackingService>);
|
||||
new PackingController(svc).update(user, '5', '9', { name: 'X', checked: true }, 'sock');
|
||||
expect(updateItem).toHaveBeenCalledWith('5', '9', expect.objectContaining({ name: 'X', checked: true }), ['name', 'checked'], undefined);
|
||||
// acting user id is forwarded so privatizing an unowned item can stamp the owner (#858)
|
||||
expect(updateItem).toHaveBeenCalledWith('5', '9', expect.objectContaining({ name: 'X', checked: true }), ['name', 'checked'], undefined, user.id);
|
||||
// A public item (is_private undefined, was public) broadcasts to the whole room.
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:updated', { item: { id: 9, name: 'X' } }, 'sock');
|
||||
});
|
||||
|
||||
it('keeps a private update scoped to the owner (#858)', () => {
|
||||
const updateItem = vi.fn().mockReturnValue({ id: 9, name: 'X', is_private: 1, owner_id: 1 });
|
||||
const broadcast = vi.fn();
|
||||
const broadcastItem = vi.fn();
|
||||
// Was already private before the change → owner-only update, no room broadcast.
|
||||
const getItemPrivacy = vi.fn().mockReturnValue({ is_private: 1, owner_id: 1 });
|
||||
const svc = makeService({ updateItem, broadcast, broadcastItem, getItemPrivacy } as Partial<PackingService>);
|
||||
new PackingController(svc).update(user, '5', '9', { name: 'X' }, 'sock');
|
||||
expect(broadcastItem).toHaveBeenCalledWith('5', 'packing:updated', { item: { id: 9, name: 'X', is_private: 1, owner_id: 1 } }, { id: 9, name: 'X', is_private: 1, owner_id: 1 }, 'sock');
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops a freshly-privatized item from the room and re-adds it for the owner (#858)', () => {
|
||||
const updateItem = vi.fn().mockReturnValue({ id: 9, name: 'X', is_private: 1, owner_id: 1 });
|
||||
const broadcast = vi.fn();
|
||||
const broadcastItem = vi.fn();
|
||||
// Was public before → public→private transition.
|
||||
const getItemPrivacy = vi.fn().mockReturnValue({ is_private: 0, owner_id: 1 });
|
||||
const svc = makeService({ updateItem, broadcast, broadcastItem, getItemPrivacy } as Partial<PackingService>);
|
||||
new PackingController(svc).update(user, '5', '9', { is_private: true }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, 'sock');
|
||||
expect(broadcastItem).toHaveBeenCalledWith('5', 'packing:created', expect.anything(), expect.anything(), 'sock');
|
||||
});
|
||||
|
||||
it('re-shares a freshly-public item to the whole room (#858)', () => {
|
||||
const updated = { id: 9, name: 'X', is_private: 0, owner_id: 1 };
|
||||
const updateItem = vi.fn().mockReturnValue(updated);
|
||||
const broadcast = vi.fn();
|
||||
// Was private before → private→public transition: create for those missing it, then update for all.
|
||||
const getItemPrivacy = vi.fn().mockReturnValue({ is_private: 1, owner_id: 1 });
|
||||
const svc = makeService({ updateItem, broadcast, getItemPrivacy } as Partial<PackingService>);
|
||||
new PackingController(svc).update(user, '5', '9', { is_private: false }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: updated }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:updated', { item: updated }, 'sock');
|
||||
});
|
||||
|
||||
it('forwards the X-Base-Updated-At token and 409s on a conflict (#1135)', () => {
|
||||
const updateItem = vi.fn().mockReturnValue({ conflict: true, server: { id: 9, name: 'Theirs' } });
|
||||
const broadcast = vi.fn();
|
||||
@@ -130,7 +173,7 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
|
||||
expect(thrown(() => new PackingController(svc).update(user, '5', '9', { name: 'Mine' }, 'sock', '2026-01-01 00:00:00'))).toEqual({
|
||||
status: 409, body: { error: 'conflict', server: { id: 9, name: 'Theirs' } },
|
||||
});
|
||||
expect(updateItem).toHaveBeenCalledWith('5', '9', expect.objectContaining({ name: 'Mine' }), ['name'], '2026-01-01 00:00:00');
|
||||
expect(updateItem).toHaveBeenCalledWith('5', '9', expect.objectContaining({ name: 'Mine' }), ['name'], '2026-01-01 00:00:00', user.id);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -153,18 +196,19 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
|
||||
|
||||
describe('DELETE /:id (remove)', () => {
|
||||
it('404 when the item is missing', () => {
|
||||
const svc = makeService({ deleteItem: vi.fn().mockReturnValue(false) } as Partial<PackingService>);
|
||||
const svc = makeService({ deleteItem: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
|
||||
expect(thrown(() => new PackingController(svc).remove(user, '5', '9'))).toEqual({
|
||||
status: 404, body: { error: 'Item not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes the item and broadcasts', () => {
|
||||
const deleteItem = vi.fn().mockReturnValue(true);
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ deleteItem, broadcast } as Partial<PackingService>);
|
||||
it('deletes the item and broadcasts (scoped to the owner when private, #858)', () => {
|
||||
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>);
|
||||
expect(new PackingController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, 'sock');
|
||||
expect(broadcastItem).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, deleted, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -44,14 +44,37 @@ describe('PackingService (wrapper delegation + helpers)', () => {
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: 1 }, 'sock');
|
||||
});
|
||||
|
||||
describe('broadcastItem (private-item scoping, #858)', () => {
|
||||
it('broadcasts a shared item to the whole room (no onlyUserId)', () => {
|
||||
svc().broadcastItem('5', 'packing:created', { item: 1 }, { is_private: 0, owner_id: 7 }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: 1 }, 'sock', undefined);
|
||||
});
|
||||
|
||||
it('scopes a private item to its owner', () => {
|
||||
svc().broadcastItem('5', 'packing:created', { item: 1 }, { is_private: 1, owner_id: 7 }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: 1 }, 'sock', 7);
|
||||
});
|
||||
|
||||
it('falls back to a room broadcast when the private item has no owner', () => {
|
||||
svc().broadcastItem('5', 'packing:created', { item: 1 }, { is_private: 1, owner_id: null }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: 1 }, 'sock', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it('getItemPrivacy reads the privacy fields for an item', () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ is_private: 1, owner_id: 3 });
|
||||
expect(svc().getItemPrivacy('5', '9')).toEqual({ is_private: 1, owner_id: 3 });
|
||||
expect(dbMock.prepare).toHaveBeenCalledWith(expect.stringContaining('is_private, owner_id'));
|
||||
});
|
||||
|
||||
it('forwards every item/bag/template/assignee call to the legacy service', () => {
|
||||
const s = svc();
|
||||
s.verifyTripAccess('5', 1); expect(pk.verifyTripAccess).toHaveBeenCalledWith('5', 1);
|
||||
s.listItems('5'); expect(pk.listItems).toHaveBeenCalledWith('5');
|
||||
s.createItem('5', { name: 'a' }); expect(pk.createItem).toHaveBeenCalledWith('5', { name: 'a' });
|
||||
s.updateItem('5', '2', { name: 'b' } as never, ['name']); expect(pk.updateItem).toHaveBeenCalledWith('5', '2', { name: 'b' }, ['name'], undefined);
|
||||
s.listItems('5'); expect(pk.listItems).toHaveBeenCalledWith('5', undefined);
|
||||
s.createItem('5', { name: 'a' }); expect(pk.createItem).toHaveBeenCalledWith('5', { name: 'a' }, undefined);
|
||||
s.updateItem('5', '2', { name: 'b' } as never, ['name']); expect(pk.updateItem).toHaveBeenCalledWith('5', '2', { name: 'b' }, ['name'], undefined, undefined);
|
||||
s.deleteItem('5', '2'); expect(pk.deleteItem).toHaveBeenCalledWith('5', '2');
|
||||
s.bulkImport('5', [{ name: 'x' }] as never); expect(pk.bulkImport).toHaveBeenCalledWith('5', [{ name: 'x' }]);
|
||||
s.bulkImport('5', [{ name: 'x' }] as never); expect(pk.bulkImport).toHaveBeenCalledWith('5', [{ name: 'x' }], undefined);
|
||||
s.reorderItems('5', [3, 1] as never); expect(pk.reorderItems).toHaveBeenCalledWith('5', [3, 1]);
|
||||
s.listBags('5'); expect(pk.listBags).toHaveBeenCalledWith('5');
|
||||
s.createBag('5', { name: 'Bag' }); expect(pk.createBag).toHaveBeenCalledWith('5', { name: 'Bag' });
|
||||
|
||||
@@ -42,6 +42,10 @@ import {
|
||||
createBag,
|
||||
deleteBag,
|
||||
bulkImport,
|
||||
createItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
listItems,
|
||||
} from '../../../src/services/packingService';
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
@@ -299,3 +303,78 @@ describe('bulkImport with quantity field', () => {
|
||||
expect(byName('Charger').quantity).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Private items (#858) ──────────────────────────────────────────────────────
|
||||
|
||||
describe('private items (#858)', () => {
|
||||
it('PACK-SVC-014: createItem stamps the owner and is_private flag', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const shared = createItem(trip.id, { name: 'Tent' }, user.id) as any;
|
||||
const secret = createItem(trip.id, { name: 'Gift', is_private: true }, user.id) as any;
|
||||
|
||||
expect(shared.is_private).toBe(0);
|
||||
expect(shared.owner_id).toBe(user.id);
|
||||
expect(secret.is_private).toBe(1);
|
||||
expect(secret.owner_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('PACK-SVC-015: listItems hides another member\'s private items but shows the owner theirs', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
createItem(trip.id, { name: 'Shared' }, owner.id);
|
||||
createItem(trip.id, { name: 'Private', is_private: true }, owner.id);
|
||||
|
||||
const ownerView = listItems(trip.id, owner.id) as any[];
|
||||
const otherView = listItems(trip.id, other.id) as any[];
|
||||
const unscoped = listItems(trip.id) as any[];
|
||||
|
||||
expect(ownerView.map(i => i.name).sort()).toEqual(['Private', 'Shared']);
|
||||
expect(otherView.map(i => i.name)).toEqual(['Shared']);
|
||||
// Without a viewer (internal callers) nothing is filtered.
|
||||
expect(unscoped).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('PACK-SVC-016: updateItem toggles privacy and claims an unowned item for the actor', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Legacy-style row with no owner.
|
||||
const id = Number((testDb.prepare('INSERT INTO packing_items (trip_id, name, checked, sort_order) VALUES (?, ?, 0, 0)').run(trip.id, 'Legacy') as any).lastInsertRowid);
|
||||
|
||||
const updated = updateItem(trip.id, id, { is_private: true }, ['is_private'], undefined, user.id) as any;
|
||||
expect(updated.is_private).toBe(1);
|
||||
expect(updated.owner_id).toBe(user.id);
|
||||
|
||||
const back = updateItem(trip.id, id, { is_private: false }, ['is_private'], undefined, user.id) as any;
|
||||
expect(back.is_private).toBe(0);
|
||||
// Ownership is retained once claimed.
|
||||
expect(back.owner_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('PACK-SVC-017: deleteItem returns the removed row (with privacy fields)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createItem(trip.id, { name: 'Private', is_private: true }, user.id) as any;
|
||||
|
||||
const deleted = deleteItem(trip.id, item.id) as any;
|
||||
expect(deleted).not.toBeNull();
|
||||
expect(deleted.is_private).toBe(1);
|
||||
expect(deleted.owner_id).toBe(user.id);
|
||||
expect(deleteItem(trip.id, item.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('PACK-SVC-018: bulkImport stamps the owner on every item', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
bulkImport(trip.id, [{ name: 'A' }, { name: 'B', is_private: true }], user.id);
|
||||
const rows = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ? ORDER BY name').all(trip.id) as any[];
|
||||
expect(rows.every(r => r.owner_id === user.id)).toBe(true);
|
||||
expect(rows.find(r => r.name === 'B').is_private).toBe(1);
|
||||
expect(rows.find(r => r.name === 'A').is_private).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,5 +179,8 @@ const packing: TranslationStrings = {
|
||||
category: 'الصحة',
|
||||
},
|
||||
],
|
||||
'packing.makePrivate': 'جعله خاصًا',
|
||||
'packing.makePublic': 'مشاركة',
|
||||
'packing.privateHint': 'خاص — مرئي لك فقط',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -179,5 +179,8 @@ const packing: TranslationStrings = {
|
||||
category: 'Saúde',
|
||||
},
|
||||
],
|
||||
'packing.makePrivate': 'Tornar privado',
|
||||
'packing.makePublic': 'Compartilhar',
|
||||
'packing.privateHint': 'Privado — visível apenas para você',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -180,5 +180,8 @@ const packing: TranslationStrings = {
|
||||
category: 'Zdraví',
|
||||
},
|
||||
],
|
||||
'packing.makePrivate': 'Nastavit jako soukromé',
|
||||
'packing.makePublic': 'Sdílet',
|
||||
'packing.privateHint': 'Soukromé — vidíte jen vy',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -51,6 +51,9 @@ const packing: TranslationStrings = {
|
||||
'packing.bagName': 'Name...',
|
||||
'packing.addBag': 'Gepäck hinzufügen',
|
||||
'packing.changeCategory': 'Kategorie ändern',
|
||||
'packing.makePrivate': 'Privat machen',
|
||||
'packing.makePublic': 'Teilen',
|
||||
'packing.privateHint': 'Privat — nur für dich sichtbar',
|
||||
'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',
|
||||
|
||||
@@ -51,6 +51,9 @@ const packing: TranslationStrings = {
|
||||
'packing.bagName': 'Bag name...',
|
||||
'packing.addBag': 'Add bag',
|
||||
'packing.changeCategory': 'Change Category',
|
||||
'packing.makePrivate': 'Make private',
|
||||
'packing.makePublic': 'Make shared',
|
||||
'packing.privateHint': 'Private — only visible to you',
|
||||
'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',
|
||||
|
||||
@@ -180,5 +180,8 @@ const packing: TranslationStrings = {
|
||||
category: 'Salud',
|
||||
},
|
||||
],
|
||||
'packing.makePrivate': 'Hacer privado',
|
||||
'packing.makePublic': 'Compartir',
|
||||
'packing.privateHint': 'Privado — solo visible para ti',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -179,5 +179,8 @@ const packing: TranslationStrings = {
|
||||
category: 'Santé',
|
||||
},
|
||||
],
|
||||
'packing.makePrivate': 'Rendre privé',
|
||||
'packing.makePublic': 'Partager',
|
||||
'packing.privateHint': 'Privé — visible par vous uniquement',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -90,5 +90,8 @@ const packing: TranslationStrings = {
|
||||
{ name: 'Τσιρότα', category: 'Υγεία' },
|
||||
{ name: 'Απολυμαντικό', category: 'Υγεία' },
|
||||
],
|
||||
'packing.makePrivate': 'Ορισμός ως ιδιωτικό',
|
||||
'packing.makePublic': 'Κοινή χρήση',
|
||||
'packing.privateHint': 'Ιδιωτικό — ορατό μόνο σε εσάς',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -180,5 +180,8 @@ const packing: TranslationStrings = {
|
||||
category: 'Egészség',
|
||||
},
|
||||
],
|
||||
'packing.makePrivate': 'Priváttá tétel',
|
||||
'packing.makePublic': 'Megosztás',
|
||||
'packing.privateHint': 'Privát — csak Ön látja',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -180,5 +180,8 @@ const packing: TranslationStrings = {
|
||||
category: 'Kesehatan',
|
||||
},
|
||||
],
|
||||
'packing.makePrivate': 'Jadikan pribadi',
|
||||
'packing.makePublic': 'Bagikan',
|
||||
'packing.privateHint': 'Pribadi — hanya terlihat oleh Anda',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -180,5 +180,8 @@ const packing: TranslationStrings = {
|
||||
category: 'Salute',
|
||||
},
|
||||
],
|
||||
'packing.makePrivate': 'Rendi privato',
|
||||
'packing.makePublic': 'Condividi',
|
||||
'packing.privateHint': 'Privato — visibile solo a te',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -179,5 +179,8 @@ const packing: TranslationStrings = {
|
||||
category: '健康',
|
||||
},
|
||||
],
|
||||
'packing.makePrivate': '非公開にする',
|
||||
'packing.makePublic': '共有する',
|
||||
'packing.privateHint': '非公開 — 自分のみ表示',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -179,5 +179,8 @@ const packing: TranslationStrings = {
|
||||
category: '건강',
|
||||
},
|
||||
],
|
||||
'packing.makePrivate': '비공개로 설정',
|
||||
'packing.makePublic': '공유',
|
||||
'packing.privateHint': '비공개 — 나만 볼 수 있음',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -179,5 +179,8 @@ const packing: TranslationStrings = {
|
||||
category: 'Gezondheid',
|
||||
},
|
||||
],
|
||||
'packing.makePrivate': 'Privé maken',
|
||||
'packing.makePublic': 'Delen',
|
||||
'packing.privateHint': 'Privé — alleen zichtbaar voor jou',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -180,5 +180,8 @@ const packing: TranslationStrings = {
|
||||
category: 'Zdrowie',
|
||||
},
|
||||
],
|
||||
'packing.makePrivate': 'Ustaw jako prywatne',
|
||||
'packing.makePublic': 'Udostępnij',
|
||||
'packing.privateHint': 'Prywatne — widoczne tylko dla Ciebie',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -179,5 +179,8 @@ const packing: TranslationStrings = {
|
||||
category: 'Здоровье',
|
||||
},
|
||||
],
|
||||
'packing.makePrivate': 'Сделать личным',
|
||||
'packing.makePublic': 'Сделать общим',
|
||||
'packing.privateHint': 'Личное — видно только вам',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -180,5 +180,8 @@ const packing: TranslationStrings = {
|
||||
category: 'Hälsa',
|
||||
},
|
||||
],
|
||||
'packing.makePrivate': 'Gör privat',
|
||||
'packing.makePublic': 'Dela',
|
||||
'packing.privateHint': 'Privat — endast synligt för dig',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -90,5 +90,8 @@ const packing: TranslationStrings = {
|
||||
{ name: 'Yara bandı', category: 'Sağlık' },
|
||||
{ name: 'Dezenfektan', category: 'Sağlık' },
|
||||
],
|
||||
'packing.makePrivate': 'Gizli yap',
|
||||
'packing.makePublic': 'Paylaş',
|
||||
'packing.privateHint': 'Gizli — yalnızca siz görebilirsiniz',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -179,5 +179,8 @@ const packing: TranslationStrings = {
|
||||
category: 'Здоров’я',
|
||||
},
|
||||
],
|
||||
'packing.makePrivate': 'Зробити приватним',
|
||||
'packing.makePublic': 'Зробити спільним',
|
||||
'packing.privateHint': 'Приватне — бачите лише ви',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -180,5 +180,8 @@ const packing: TranslationStrings = {
|
||||
category: 'Sức khỏe',
|
||||
},
|
||||
],
|
||||
'packing.makePrivate': 'Đặt riêng tư',
|
||||
'packing.makePublic': 'Chia sẻ',
|
||||
'packing.privateHint': 'Riêng tư — chỉ mình bạn thấy',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -178,5 +178,8 @@ const packing: TranslationStrings = {
|
||||
category: '健康',
|
||||
},
|
||||
],
|
||||
'packing.makePrivate': '設為私密',
|
||||
'packing.makePublic': '共用',
|
||||
'packing.privateHint': '私密 — 僅你可見',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -178,5 +178,8 @@ const packing: TranslationStrings = {
|
||||
category: '健康',
|
||||
},
|
||||
],
|
||||
'packing.makePrivate': '设为私密',
|
||||
'packing.makePublic': '共享',
|
||||
'packing.privateHint': '私密 — 仅你可见',
|
||||
};
|
||||
export default packing;
|
||||
|
||||
@@ -29,6 +29,11 @@ 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.
|
||||
is_private: z.number().optional(),
|
||||
owner_id: z.number().nullable().optional(),
|
||||
created_at: z.string().optional(),
|
||||
// Optimistic-concurrency token for offline conflict detection (#1135). Added
|
||||
// by migration 98; older rows backfill from created_at.
|
||||
@@ -70,6 +75,8 @@ 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).
|
||||
is_private: z.boolean().optional(),
|
||||
});
|
||||
export type PackingCreateItemRequest = z.infer<typeof packingCreateItemRequestSchema>;
|
||||
|
||||
@@ -80,6 +87,8 @@ export const packingUpdateItemRequestSchema = z.object({
|
||||
weight_grams: z.number().nullable().optional(),
|
||||
bag_id: z.number().nullable().optional(),
|
||||
quantity: z.number().optional(),
|
||||
// Toggle the item's privacy (#858).
|
||||
is_private: z.boolean().optional(),
|
||||
});
|
||||
export type PackingUpdateItemRequest = z.infer<typeof packingUpdateItemRequestSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user