From 0d6c603e9c0dab2d6e81d4f982769adb50a6aa62 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 18:17:17 +0100 Subject: [PATCH] refactor: remove legacy spaces and migrate lists to standalone resources with direct sharing --- .../src/features/lists/SharedListsView.tsx | 319 +++------ frontend/src/lib/api.ts | 48 +- frontend/src/lib/types.ts | 20 +- internal/api/server.go | 307 ++++----- internal/api/server_test.go | 80 ++- internal/domain/models.go | 52 +- internal/storage/sqlite.go | 650 +++++++----------- internal/storage/sqlite_test.go | 38 +- internal/ws/hub.go | 52 +- 9 files changed, 616 insertions(+), 950 deletions(-) diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index e992ece..8e195ef 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -9,13 +9,10 @@ import { Radio, Sparkles, Share, - Share2, - User as UserIcon, X, } from 'lucide-react'; -import { ListItem, SharedList, Space, Team, User, WSMessage } from '../../lib/types'; +import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types'; import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api'; -import { UserAutocomplete } from '../UserAutocomplete'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; interface SharedListsViewProps { @@ -69,8 +66,6 @@ export function getItemDisplayContent(item: ListItem): string { } export const SharedListsView: React.FC = ({ onRefreshGlobal, currentUser }) => { - const [spaces, setSpaces] = useState([]); - const [activeSpace, setActiveSpace] = useState(null); const [lists, setLists] = useState([]); const [activeListId, setActiveListId] = useState(''); @@ -85,61 +80,47 @@ export const SharedListsView: React.FC = ({ onRefreshGloba const [showShareModal, setShowShareModal] = useState(false); const [collaborators, setCollaborators] = useState([]); const [teams, setTeams] = useState([]); - const [addShareType, setAddShareType] = useState<'team' | 'user'>('team'); - const [selectedTeamId, setSelectedTeamId] = useState(''); - const [selectedShareUser, setSelectedShareUser] = useState(null); - const [inviteTarget, setInviteTarget] = useState(''); - const [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor'); - const [invitingLoading, setInvitingLoading] = useState(false); - const [shareError, setShareError] = useState(null); const wsRef = useRef(null); - const loadSpaces = async () => { - let sp = await api.getSpaces(); - if (sp.length === 0) { - try { - const created = await api.createSpace('Meine Listen', '🛒', 'Persönliche und geteilte Einkaufslisten'); - sp = [created]; - } catch { - // fallback + const loadLists = async () => { + try { + let all = await api.getAllLists(); + if (all.length === 0) { + try { + const created = await api.createList('Wocheneinkauf', '🛒', 'shopping'); + all = [created]; + } catch { + // fallback + } } - } - setSpaces(sp); - if (sp.length > 0) { - if (!activeSpace) { - setActiveSpace(sp[0]); - } else { - const found = sp.find((s) => s.id === activeSpace.id); - if (found) setActiveSpace(found); + setLists(all); + if (all.length > 0 && (!activeListId || !all.some((l) => l.id === activeListId))) { + setActiveListId(all[0].id); } - } - }; - - const loadListsForSpace = async (spaceId: string) => { - const lst = await api.getSpaceLists(spaceId); - setLists(lst); - if (lst.length > 0 && (!activeListId || !lst.some((l) => l.id === activeListId))) { - setActiveListId(lst[0].id); + } catch { + // ignore } }; useEffect(() => { - loadSpaces(); + loadLists(); }, []); + const activeList = useMemo(() => { + return lists.find((l) => l.id === activeListId) || lists[0] || null; + }, [lists, activeListId]); + useEffect(() => { - if (!activeSpace) return; - loadListsForSpace(activeSpace.id); + if (!activeList?.id) return; - // Connect Realtime WebSocket for this Space if (wsRef.current) { wsRef.current.close(); } let ws: WebSocket | null = null; try { - ws = new WebSocket(buildWebSocketURL(activeSpace.id)); + ws = new WebSocket(buildWebSocketURL(activeList.id)); wsRef.current = ws; ws.onopen = () => { @@ -153,41 +134,49 @@ export const SharedListsView: React.FC = ({ onRefreshGloba ws.onmessage = (evt) => { try { const msg: WSMessage = JSON.parse(evt.data); - if (msg.event === 'ITEM_CREATED' && msg.list_id) { + const msgListId = msg.listId || (msg as any).list_id; + if (msg.event === 'ITEM_CREATED' && msgListId) { const newItem: ListItem = msg.payload; setLists((prev) => prev.map((l) => { - if (l.id !== msg.list_id) return l; + if (l.id !== msgListId) return l; if (l.items.some((i) => i.id === newItem.id)) return l; return { ...l, items: [newItem, ...l.items] }; }) ); - } else if (msg.event === 'ITEM_TOGGLED' && msg.list_id) { + } else if (msg.event === 'ITEM_TOGGLED' && msgListId) { const updatedItem: ListItem = msg.payload; setLists((prev) => prev.map((l) => { - if (l.id !== msg.list_id) return l; + if (l.id !== msgListId) return l; return { ...l, items: l.items.map((i) => (i.id === updatedItem.id ? updatedItem : i)), }; }) ); - } else if (msg.event === 'ITEM_DELETED' && msg.list_id) { + } else if (msg.event === 'ITEM_DELETED' && msgListId) { const deletedId = msg.payload?.itemId; setLists((prev) => prev.map((l) => { - if (l.id !== msg.list_id) return l; + if (l.id !== msgListId) return l; return { ...l, items: l.items.filter((i) => i.id !== deletedId) }; }) ); - } else if (msg.event === 'LIST_CLEARED' && msg.list_id) { - const updatedList: SharedList = msg.payload; - setLists((prev) => prev.map((l) => (l.id === msg.list_id ? updatedList : l))); - } else if (msg.event === 'LIST_CREATED' || msg.event === 'LIST_DELETED') { - loadListsForSpace(activeSpace.id); - } else if (msg.event === 'MEMBER_ADDED' || msg.event === 'MEMBER_REMOVED') { - loadSpaces(); + } else if (msg.event === 'LIST_CLEARED' && msgListId) { + setLists((prev) => + prev.map((l) => { + if (l.id !== msgListId) return l; + return { ...l, items: l.items.filter((i) => !i.isCompleted) }; + }) + ); + } else if ( + msg.event === 'LIST_CREATED' || + msg.event === 'LIST_DELETED' || + msg.event === 'LIST_SHARED' || + msg.event === 'LIST_UNSHARED' + ) { + loadLists(); } } catch { // ignore malformed frames @@ -200,27 +189,25 @@ export const SharedListsView: React.FC = ({ onRefreshGloba return () => { if (ws) ws.close(); }; - }, [activeSpace?.id]); + }, [activeList?.id]); useEffect(() => { const handleGlobalWs = (e: any) => { const msg = e.detail; if (!msg) return; if ( - msg.event === 'SPACE_JOINED' || - msg.event === 'SPACE_LEFT' || - msg.event === 'MEMBER_ADDED' || - msg.event === 'MEMBER_REMOVED' + msg.event === 'LIST_SHARED' || + msg.event === 'LIST_UNSHARED' || + msg.event === 'LIST_CREATED' || + msg.event === 'LIST_DELETED' ) { - loadSpaces(); + loadLists(); } }; window.addEventListener('mindsift:ws', handleGlobalWs); return () => window.removeEventListener('mindsift:ws', handleGlobalWs); }, []); - const activeList = lists.find((l) => l.id === activeListId) || lists[0] || null; - const handleAddItem = async (e: React.FormEvent) => { e.preventDefault(); if (!activeList || !newItemText.trim()) return; @@ -275,16 +262,20 @@ export const SharedListsView: React.FC = ({ onRefreshGloba if (!activeList) return; triggerHaptic(20); await api.clearCompletedItems(activeList.id); - await loadListsForSpace(activeSpace!.id); + setLists((prev) => + prev.map((l) => + l.id === activeList.id ? { ...l, items: l.items.filter((i) => !i.isCompleted) } : l + ) + ); }; const handleCreateList = async (e: React.FormEvent) => { e.preventDefault(); - if (!activeSpace || !newListTitle.trim()) return; - const created = await api.createList(activeSpace.id, newListTitle.trim(), newListIcon, 'shopping'); + if (!newListTitle.trim()) return; + const created = await api.createList(newListTitle.trim(), newListIcon, 'shopping'); setNewListTitle(''); setShowNewListModal(false); - await loadListsForSpace(activeSpace.id); + await loadLists(); setActiveListId(created.id); onRefreshGlobal(); }; @@ -293,9 +284,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba if (!window.confirm('Diese Liste wirklich löschen?')) return; try { await api.deleteList(listId); - if (activeSpace) { - await loadListsForSpace(activeSpace.id); - } + await loadLists(); onRefreshGlobal(); } catch (err: any) { alert(err.message || 'Liste konnte nicht gelöscht werden'); @@ -303,96 +292,22 @@ export const SharedListsView: React.FC = ({ onRefreshGloba }; const openShareModal = async () => { - if (!activeSpace) return; + if (!activeList) return; setShowShareModal(true); - setShareError(null); - setSelectedTeamId(''); - setSelectedShareUser(null); try { const [usersList, teamsList, sharesList] = await Promise.all([ api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []), api.getUserTeams().catch(() => []), - api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]), + api.getListShares(activeList.id).catch(() => []), ]); setCollaborators(usersList); setTeams(teamsList); - setActiveSpace((prev) => (prev ? { ...prev, shares: sharesList } : prev)); + setLists((prev) => prev.map((l) => (l.id === activeList.id ? { ...l, shares: sharesList } : l))); } catch { // ignore } }; - const handleAddShare = async (e: React.FormEvent) => { - e.preventDefault(); - if (!activeSpace) return; - setInvitingLoading(true); - setShareError(null); - try { - if (addShareType === 'team') { - if (!selectedTeamId) { - setShareError('Bitte ein Team oder eine Gruppe auswählen'); - setInvitingLoading(false); - return; - } - await api.addSpaceMember(activeSpace.id, { - teamId: selectedTeamId, - role: inviteRole, - }); - setSelectedTeamId(''); - } else { - if (!selectedShareUser) { - setShareError('Bitte einen bestehenden Benutzer auswählen'); - setInvitingLoading(false); - return; - } - await api.addSpaceMember(activeSpace.id, { - userId: selectedShareUser.id, - role: inviteRole, - }); - setSelectedShareUser(null); - } - - const [sp, freshShares] = await Promise.all([ - api.getSpaces(), - api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]), - ]); - setSpaces(sp); - const updated = sp.find((s) => s.id === activeSpace.id); - if (updated) { - setActiveSpace({ ...updated, shares: freshShares }); - } - onRefreshGlobal(); - } catch (err: any) { - setShareError(err.message || 'Freigabe konnte nicht hinzugefügt werden'); - } finally { - setInvitingLoading(false); - } - }; - - const handleRemoveMember = async (targetId: string) => { - if (!activeSpace) return; - if (!window.confirm('Freigabe wirklich aufheben?')) return; - setInvitingLoading(true); - setShareError(null); - try { - await api.removeSpaceMember(activeSpace.id, targetId); - const [sp, freshShares] = await Promise.all([ - api.getSpaces(), - api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]), - ]); - setSpaces(sp); - const updated = sp.find((s) => s.id === activeSpace.id); - if (updated) { - setActiveSpace({ ...updated, shares: freshShares }); - } - onRefreshGlobal(); - } catch (err: any) { - setShareError(err.message || 'Freigabe konnte nicht entfernt werden'); - } finally { - setInvitingLoading(false); - } - }; - const openItems = activeList?.items.filter((i) => !i.isCompleted) || []; const completedItems = activeList?.items.filter((i) => i.isCompleted) || []; @@ -486,16 +401,11 @@ export const SharedListsView: React.FC = ({ onRefreshGloba {/* Right Header Section: Geteilt Badge, Icon-Only Share Button & Live Sync */}
- {activeSpace && (() => { - const isOwner = !activeSpace.ownerId || activeSpace.ownerId === currentUser?.id; - const sharesCount = - (activeSpace.shares?.length || 0) + - (activeSpace.members?.filter((m) => m.userId !== activeSpace.ownerId).length || 0); - const isShared = - (activeSpace.shares && activeSpace.shares.length > 0) || - (activeSpace.members && activeSpace.members.length > 1); - const ownerMember = activeSpace.members?.find((m) => m.role === 'owner' || m.userId === activeSpace.ownerId); - const ownerDisplayName = activeSpace.ownerName || ownerMember?.displayName || ownerMember?.email || 'Eigentümer'; + {activeList && (() => { + const isOwner = activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin'); + const sharesCount = activeList.shares?.length || 0; + const isShared = sharesCount > 0; + const ownerDisplayName = activeList.ownerName || 'Eigentümer'; return (
@@ -508,7 +418,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten" > - Geteilt ({sharesCount > 0 ? sharesCount : activeSpace.members?.length || 1}) + Geteilt ({sharesCount}) ) : ( @@ -739,78 +649,47 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
) : null} - {/* Unified Space & Shopping List Sharing Modal */} - {showShareModal && activeSpace && ( + {/* Unified Shopping List Sharing Modal */} + {showShareModal && activeList && ( setShowShareModal(false)} - title={`Einkaufsliste teilen: ${activeSpace.name}`} - resourceName={activeSpace.name} - resourceIcon={activeSpace.icon || '🛒'} - isOwner={activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin'} + title={`Liste teilen: ${activeList.title}`} + resourceName={activeList.title} + resourceIcon={activeList.icon || '🛒'} + isOwner={activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin')} currentUserId={currentUser?.id} - shares={[ - ...(activeSpace.shares || []) - .filter((s) => s.targetType === 'team') - .map((s) => ({ - id: s.id, - targetType: 'team' as const, - targetId: s.targetId, - targetName: s.targetName || 'Gruppe', - permission: s.role === 'viewer' ? ('viewer' as const) : ('editor' as const), - role: s.role, - createdAt: s.createdAt, - })), - ...(activeSpace.members || []) - .filter((m) => m.userId !== activeSpace.ownerId) - .map((m) => ({ - id: m.userId, - targetType: 'user' as const, - targetId: m.userId, - targetName: m.displayName || m.email, - targetEmail: m.email, - permission: m.role === 'viewer' ? ('viewer' as const) : ('editor' as const), - role: m.role, - createdAt: m.joinedAt, - })), - ]} + shares={(activeList.shares || []).map((s) => ({ + id: s.id, + targetType: s.targetType, + targetId: s.targetId, + targetName: s.targetName || (s.targetType === 'team' ? 'Gruppe' : 'Benutzer'), + targetEmail: s.targetEmail, + permission: s.role === 'viewer' ? ('viewer' as const) : ('editor' as const), + role: s.role, + createdAt: s.createdAt, + }))} teams={teams} collaborators={collaborators} allowRoleSelection={true} onAddShare={async (targetType, targetId, permission) => { - if (targetType === 'team') { - await api.addSpaceMember(activeSpace.id, { - teamId: targetId, - role: permission, - }); - } else { - await api.addSpaceMember(activeSpace.id, { - userId: targetId, - role: permission, - }); - } - const [sp, freshShares] = await Promise.all([ - api.getSpaces(), - api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]), - ]); - setSpaces(sp); - const updated = sp.find((s) => s.id === activeSpace.id); - if (updated) { - setActiveSpace({ ...updated, shares: freshShares }); - } + await api.addListShare(activeList.id, { + targetType, + targetId, + role: permission, + }); + const freshShares = await api.getListShares(activeList.id); + setLists((prev) => + prev.map((l) => (l.id === activeList.id ? { ...l, shares: freshShares } : l)) + ); onRefreshGlobal(); }} onRemoveShare={async (shareId) => { - await api.removeSpaceMember(activeSpace.id, shareId); - const [sp, freshShares] = await Promise.all([ - api.getSpaces(), - api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]), - ]); - setSpaces(sp); - const updated = sp.find((s) => s.id === activeSpace.id); - if (updated) { - setActiveSpace({ ...updated, shares: freshShares }); - } + await api.removeListShare(activeList.id, shareId); + const freshShares = await api.getListShares(activeList.id); + setLists((prev) => + prev.map((l) => (l.id === activeList.id ? { ...l, shares: freshShares } : l)) + ); onRefreshGlobal(); }} /> diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 192a715..843efa4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -8,6 +8,7 @@ import { DocLockInfo, KanbanColumn, ListItem, + ListShare, Organization, PriorityLevel, SharedList, @@ -639,17 +640,36 @@ export const api = { return ensureArray(res).map((l) => ({ ...l, items: ensureArray(l.items), + shares: ensureArray(l.shares), })); }, - async createList(spaceId: string, title: string, icon: string, listType: 'shopping' | 'checklist' | 'vacation'): Promise { - const l = await request(`/api/v1/spaces/${spaceId}/lists`, { + async getListById(listId: string): Promise { + const l = await request(`/api/v1/lists/${listId}`); + return { + ...l, + items: ensureArray(l.items), + shares: ensureArray(l.shares), + }; + }, + + async createList(titleOrSpaceId: string, titleOrIcon?: string, iconOrType?: string, listType?: 'shopping' | 'checklist' | 'vacation'): Promise { + let title = titleOrSpaceId; + let icon = titleOrIcon || '🛒'; + let type = (iconOrType as any) || 'shopping'; + if (listType !== undefined) { + title = titleOrIcon || ''; + icon = iconOrType || '🛒'; + type = listType; + } + const l = await request('/api/v1/lists', { method: 'POST', - body: JSON.stringify({ title, icon, listType }), + body: JSON.stringify({ title, icon, listType: type }), }); return { ...l, items: ensureArray(l.items), + shares: ensureArray(l.shares), }; }, @@ -657,6 +677,22 @@ export const api = { await request(`/api/v1/lists/${listId}`, { method: 'DELETE' }); }, + async getListShares(listId: string): Promise { + const res = await request(`/api/v1/lists/${listId}/shares`); + return ensureArray(res); + }, + + async addListShare(listId: string, target: { targetType: 'team' | 'user'; targetId: string; role?: string }): Promise { + return request(`/api/v1/lists/${listId}/shares`, { + method: 'POST', + body: JSON.stringify(target), + }); + }, + + async removeListShare(listId: string, shareId: string): Promise { + await request(`/api/v1/lists/${listId}/shares/${shareId}`, { method: 'DELETE' }); + }, + async createListItem(listId: string, content: string, category = '', quantity = ''): Promise { return request(`/api/v1/lists/${listId}/items`, { method: 'POST', @@ -905,11 +941,11 @@ let wsReconnectTimer: any = null; let wsListeners: ((msg: WSMessage) => void)[] = []; let manualDisconnect = false; -export function buildWebSocketURL(spaceId?: string): string { +export function buildWebSocketURL(listId?: string): string { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const token = getToken() || ''; - if (spaceId) { - return `${protocol}//${window.location.host}/api/v1/ws/spaces/${encodeURIComponent(spaceId)}?token=${encodeURIComponent(token)}`; + if (listId) { + return `${protocol}//${window.location.host}/api/v1/ws/lists/${encodeURIComponent(listId)}?token=${encodeURIComponent(token)}`; } return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`; } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 07c49f7..05f6012 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -295,13 +295,30 @@ export interface ListItem { completedAt?: string; } +export interface ListShare { + id: string; + listId: string; + targetType: 'team' | 'user'; + targetId: string; + targetName?: string; + targetEmail?: string; + role: 'editor' | 'viewer'; + createdBy?: string; + createdAt: string; +} + export interface SharedList { id: string; - spaceId: string; + userId?: string; + ownerName?: string; + isOwner?: boolean; + role?: string; + spaceId?: string; title: string; icon: string; listType: 'shopping' | 'checklist' | 'vacation'; items: ListItem[]; + shares?: ListShare[]; updatedAt: string; createdAt: string; } @@ -331,6 +348,7 @@ export interface WSMessage { event: string; space_id?: string; list_id?: string; + listId?: string; payload: T; timestamp: string; } diff --git a/internal/api/server.go b/internal/api/server.go index b3c6046..d5113ae 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -63,6 +63,7 @@ func (s *Server) Router() http.Handler { r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest) r.Get("/api/v1/ws", s.handleWebSocket) r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket) + r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket) // Authenticated API Routes r.Group(func(r chi.Router) { @@ -125,18 +126,14 @@ func (s *Server) Router() http.Handler { r.Post("/api/v1/documents/{id}/lock/heartbeat", s.handleHeartbeatDocLock) r.Delete("/api/v1/documents/{id}/lock", s.handleUnlockDoc) - // Shared Spaces & Realtime Lists - r.Get("/api/v1/spaces", s.handleGetSpaces) - r.Post("/api/v1/spaces", s.handleCreateSpace) - r.Post("/api/v1/spaces/join", s.handleJoinSpace) - r.Get("/api/v1/spaces/{id}/shares", s.handleGetSpaceShares) - r.Post("/api/v1/spaces/{id}/members", s.handleAddSpaceMember) - r.Delete("/api/v1/spaces/{id}/members/{userId}", s.handleRemoveSpaceMember) - r.Get("/api/v1/spaces/{id}/lists", s.handleGetSpaceLists) - r.Post("/api/v1/spaces/{id}/lists", s.handleCreateList) - + // Shared Lists & Realtime Collaboration r.Get("/api/v1/lists", s.handleGetAllLists) + r.Post("/api/v1/lists", s.handleCreateList) + r.Get("/api/v1/lists/{id}", s.handleGetListByID) r.Delete("/api/v1/lists/{id}", s.handleDeleteList) + r.Get("/api/v1/lists/{id}/shares", s.handleGetListShares) + r.Post("/api/v1/lists/{id}/shares", s.handleCreateListShare) + r.Delete("/api/v1/lists/{id}/shares/{shareId}", s.handleDeleteListShare) r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem) r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem) r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem) @@ -584,8 +581,8 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) { if converted, convErr := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", target, targetListID, dueDate, taskListID); convErr == nil && converted != nil { thought = converted if target == "list_item" && targetListID != "" { - if lst, lErr := s.store.GetListByID(targetListID); lErr == nil && lst != nil { - s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst) + if lst, lErr := s.store.GetListByID(userID, targetListID); lErr == nil && lst != nil { + s.wsHub.BroadcastListEvent(lst.ID, "ITEM_CREATED", lst) } } } @@ -661,8 +658,8 @@ func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) { // If converted to a list item, broadcast via WebSocket to the space if convertTo == "list_item" && targetListID != "" { - if lst, err := s.store.GetListByID(targetListID); err == nil && lst != nil { - s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst) + if lst, err := s.store.GetListByID(userID, targetListID); err == nil && lst != nil { + s.wsHub.BroadcastListEvent(lst.ID, "ITEM_CREATED", lst) } } @@ -1073,151 +1070,7 @@ func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]bool{"unlocked": released}) } -// --- SPACES & REALTIME LISTS --- - -func (s *Server) handleGetSpaces(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - spaces, err := s.store.GetSpacesForUser(userID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, spaces) -} - -func (s *Server) handleCreateSpace(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - var req struct { - Name string `json:"name"` - Icon string `json:"icon"` - Description string `json:"description"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "Ungültiges JSON") - return - } - - sp, err := s.store.CreateSpace(userID, req.Name, req.Icon, req.Description, false) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - writeJSON(w, http.StatusCreated, sp) -} - -func (s *Server) handleJoinSpace(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - var req struct { - InviteCode string `json:"inviteCode"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "Ungültiges JSON") - return - } - - sp, err := s.store.JoinSpaceByInviteCode(userID, req.InviteCode) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - s.wsHub.BroadcastToUser(userID, "SPACE_JOINED", map[string]interface{}{"spaceId": sp.ID}) - s.wsHub.Broadcast(sp.ID, "", "MEMBER_ADDED", map[string]interface{}{"spaceId": sp.ID, "userId": userID}) - - writeJSON(w, http.StatusOK, sp) -} - -func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) { - currentUserID := auth.GetUserID(r.Context()) - spaceID := chi.URLParam(r, "id") - var req struct { - UserID string `json:"userId"` - Email string `json:"email"` - TeamID string `json:"teamId"` - Role string `json:"role"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "Ungültiges JSON") - return - } - - if req.Role != "owner" && req.Role != "editor" && req.Role != "viewer" { - req.Role = "editor" - } - - if req.TeamID != "" { - share, err := s.store.CreateSpaceTeamShare(currentUserID, spaceID, req.TeamID, req.Role) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - members, _ := s.store.GetTeamMembers(req.TeamID) - for _, tm := range members { - s.wsHub.BroadcastToUser(tm.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID}) - } - s.wsHub.Broadcast(spaceID, "", "SPACE_SHARED", share) - writeJSON(w, http.StatusCreated, share) - return - } - - target := req.UserID - if target == "" { - target = req.Email - } - if target == "" { - writeError(w, http.StatusBadRequest, "Benutzer-ID, Benutzername, E-Mail oder Team erforderlich") - return - } - - member, err := s.store.AddSpaceMember(currentUserID, spaceID, target, req.Role) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - - s.wsHub.BroadcastToUser(member.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID}) - s.wsHub.Broadcast(spaceID, "", "MEMBER_ADDED", member) - - writeJSON(w, http.StatusCreated, member) -} - -func (s *Server) handleGetSpaceShares(w http.ResponseWriter, r *http.Request) { - spaceID := chi.URLParam(r, "id") - shares, err := s.store.GetSpaceShares(spaceID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, shares) -} - -func (s *Server) handleRemoveSpaceMember(w http.ResponseWriter, r *http.Request) { - currentUserID := auth.GetUserID(r.Context()) - spaceID := chi.URLParam(r, "id") - targetUserID := chi.URLParam(r, "userId") - - // If prefixed with sm_, strip it for individual space member removal - cleanTargetID := strings.TrimPrefix(targetUserID, "sm_") - - if err := s.store.RemoveSpaceMember(currentUserID, spaceID, cleanTargetID); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - - s.wsHub.BroadcastToUser(cleanTargetID, "SPACE_LEFT", map[string]interface{}{"spaceId": spaceID}) - s.wsHub.Broadcast(spaceID, "", "MEMBER_REMOVED", map[string]interface{}{"spaceId": spaceID, "userId": cleanTargetID}) - - writeJSON(w, http.StatusOK, map[string]bool{"success": true}) -} - -func (s *Server) handleGetSpaceLists(w http.ResponseWriter, r *http.Request) { - spaceID := chi.URLParam(r, "id") - lists, err := s.store.GetListsForSpace(spaceID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, lists) -} +// --- REALTIME LISTS & SHARING --- func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) @@ -1229,8 +1082,19 @@ func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, lists) } +func (s *Server) handleGetListByID(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + listID := chi.URLParam(r, "id") + lst, err := s.store.GetListByID(userID, listID) + if err != nil { + writeError(w, http.StatusNotFound, "Liste nicht gefunden") + return + } + writeJSON(w, http.StatusOK, lst) +} + func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) { - spaceID := chi.URLParam(r, "id") + userID := auth.GetUserID(r.Context()) var req struct { Title string `json:"title"` Icon string `json:"icon"` @@ -1241,28 +1105,96 @@ func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) { return } - lst, err := s.store.CreateList(spaceID, req.Title, req.Icon, req.ListType) + lst, err := s.store.CreateList(userID, req.Title, req.Icon, req.ListType) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } - s.wsHub.Broadcast(spaceID, lst.ID, "LIST_CREATED", lst) + s.wsHub.BroadcastListEvent(lst.ID, "LIST_CREATED", lst) writeJSON(w, http.StatusCreated, lst) } func (s *Server) handleDeleteList(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) listID := chi.URLParam(r, "id") - lst, _ := s.store.GetListByID(listID) - if err := s.store.DeleteList(listID); err != nil { + if err := s.store.DeleteList(userID, listID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } - if lst != nil { - s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_DELETED", map[string]string{"listId": listID}) - } + s.wsHub.BroadcastListEvent(listID, "LIST_DELETED", map[string]string{"listId": listID}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } +func (s *Server) handleGetListShares(w http.ResponseWriter, r *http.Request) { + listID := chi.URLParam(r, "id") + shares, err := s.store.GetListShares(listID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, shares) +} + +func (s *Server) handleCreateListShare(w http.ResponseWriter, r *http.Request) { + currentUserID := auth.GetUserID(r.Context()) + listID := chi.URLParam(r, "id") + + var req struct { + TargetType string `json:"targetType"` // "user" or "team" + TargetID string `json:"targetId"` // userId/email or teamId + Role string `json:"role"` // "editor" or "viewer" + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Ungültiges JSON") + return + } + + targetType := strings.ToLower(strings.TrimSpace(req.TargetType)) + if targetType != "team" { + targetType = "user" + } + + role := strings.ToLower(strings.TrimSpace(req.Role)) + if role != "viewer" && role != "editor" { + role = "editor" + } + + share, err := s.store.AddListShare(currentUserID, listID, targetType, req.TargetID, role) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if targetType == "team" { + members, _ := s.store.GetTeamMembers(share.TargetID) + for _, tm := range members { + s.wsHub.BroadcastToUser(tm.UserID, "LIST_SHARED", share) + } + } else { + s.wsHub.BroadcastToUser(share.TargetID, "LIST_SHARED", share) + } + s.wsHub.BroadcastListEvent(listID, "LIST_SHARED", share) + + writeJSON(w, http.StatusCreated, share) +} + +func (s *Server) handleDeleteListShare(w http.ResponseWriter, r *http.Request) { + currentUserID := auth.GetUserID(r.Context()) + listID := chi.URLParam(r, "id") + shareID := chi.URLParam(r, "shareId") + + if err := s.store.RemoveListShare(currentUserID, listID, shareID); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + s.wsHub.BroadcastListEvent(listID, "LIST_UNSHARED", map[string]string{ + "listId": listID, + "shareId": shareID, + }) + writeJSON(w, http.StatusOK, map[string]bool{"success": true}) +} + func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) { listID := chi.URLParam(r, "id") var req struct { @@ -1281,9 +1213,7 @@ func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) { return } - if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { - s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_CREATED", item) - } + s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item) writeJSON(w, http.StatusCreated, item) } @@ -1301,7 +1231,7 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) { } user, _ := s.store.GetUserByID(userID) - userName := "Familie" + userName := "Mitglied" if user != nil { userName = user.DisplayName } @@ -1312,9 +1242,7 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) { return } - if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { - s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_TOGGLED", item) - } + s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item) writeJSON(w, http.StatusOK, item) } @@ -1328,12 +1256,10 @@ func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) { return } - if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { - s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_DELETED", map[string]string{ - "itemId": itemID, - "listId": listID, - }) - } + s.wsHub.BroadcastListEvent(listID, "ITEM_DELETED", map[string]string{ + "itemId": itemID, + "listId": listID, + }) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } @@ -1345,14 +1271,25 @@ func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Reques return } - if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { - s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_CLEARED", lst) - } + s.wsHub.BroadcastListEvent(listID, "LIST_CLEARED", map[string]interface{}{ + "listId": listID, + "removed": removed, + }) writeJSON(w, http.StatusOK, map[string]int64{"removed": removed}) } func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { - spaceID := chi.URLParam(r, "spaceId") + roomID := chi.URLParam(r, "listId") + if roomID == "" { + roomID = chi.URLParam(r, "spaceId") + } + if roomID == "" { + roomID = r.URL.Query().Get("listId") + } + if roomID == "" { + roomID = r.URL.Query().Get("spaceId") + } + token := r.URL.Query().Get("token") if token == "" { if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") { @@ -1365,7 +1302,7 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { userID = claims.UserID } } - s.wsHub.ServeWS(w, r, spaceID, userID) + s.wsHub.ServeWS(w, r, roomID, userID) } // --- API KEYS & WEBHOOK INGESTION --- diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 3568ce4..cf96baf 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -117,7 +117,6 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) { "/api/v1/tasks", "/api/v1/organizations", "/api/v1/teams", - "/api/v1/spaces", "/api/v1/lists", } for _, ep := range arrayEndpoints { @@ -190,7 +189,6 @@ func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) { "/api/v1/auth/me", "/api/v1/triage/deck", "/api/v1/lists", - "/api/v1/spaces", "/api/v1/tasks", "/api/v1/documents", } @@ -557,104 +555,104 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) { t.Fatalf("failed to generate token: %v", err) } - // 1. Admin creates a space - createSpaceBody := []byte(`{"name":"Einkauf WG","icon":"🛒","description":"Gemeinsame Einkaufsliste"}`) - req := httptest.NewRequest(http.MethodPost, "/api/v1/spaces", bytes.NewReader(createSpaceBody)) + // 1. Admin creates a list + createListBody := []byte(`{"title":"Einkauf WG","icon":"🛒","listType":"shopping"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/lists", bytes.NewReader(createListBody)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { - t.Fatalf("expected 201 Created from /api/v1/spaces, got %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("expected 201 Created from /api/v1/lists, got %d: %s", rec.Code, rec.Body.String()) } - var createdSpace domain.Space - _ = json.Unmarshal(rec.Body.Bytes(), &createdSpace) - if createdSpace.ID == "" { - t.Fatalf("expected created space with ID, got %+v", createdSpace) + var createdList domain.SharedList + _ = json.Unmarshal(rec.Body.Bytes(), &createdList) + if createdList.ID == "" { + t.Fatalf("expected created list with ID, got %+v", createdList) } - // 2. Admin invites normalUser by username "user" - addMemberBody := []byte(`{"userId":"user","role":"editor"}`) - req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addMemberBody)) + // 2. Admin shares list with normalUser by username "user" + addShareBody := []byte(`{"targetType":"user","targetId":"user","role":"editor"}`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addShareBody)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { - t.Fatalf("expected 201 Created from add space member, got %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("expected 201 Created from add list share, got %d: %s", rec.Code, rec.Body.String()) } - var newMember domain.SpaceMember - _ = json.Unmarshal(rec.Body.Bytes(), &newMember) - if newMember.UserID != normalUser.ID { - t.Fatalf("expected member userId %s, got %s", normalUser.ID, newMember.UserID) + var newShare domain.ListShare + _ = json.Unmarshal(rec.Body.Bytes(), &newShare) + if newShare.TargetID != normalUser.ID { + t.Fatalf("expected share targetId %s, got %s", normalUser.ID, newShare.TargetID) } - // 3. User checks their spaces; the shared space must be listed - req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil) + // 3. User checks their lists; the shared list must be listed + req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil) req.Header.Set("Authorization", "Bearer "+userToken) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("expected 200 OK for user spaces, got %d", rec.Code) + t.Fatalf("expected 200 OK for user lists, got %d", rec.Code) } - var userSpaces []domain.Space - _ = json.Unmarshal(rec.Body.Bytes(), &userSpaces) + var userLists []domain.SharedList + _ = json.Unmarshal(rec.Body.Bytes(), &userLists) found := false - for _, s := range userSpaces { - if s.ID == createdSpace.ID { + for _, l := range userLists { + if l.ID == createdList.ID { found = true break } } if !found { - t.Fatalf("expected space %s to be visible to user, got %+v", createdSpace.ID, userSpaces) + t.Fatalf("expected list %s to be visible to user, got %+v", createdList.ID, userLists) } - // 4. Admin removes user from space - req = httptest.NewRequest(http.MethodDelete, "/api/v1/spaces/"+createdSpace.ID+"/members/"+normalUser.ID, nil) + // 4. Admin removes user share from list + req = httptest.NewRequest(http.MethodDelete, "/api/v1/lists/"+createdList.ID+"/shares/"+newShare.ID, nil) req.Header.Set("Authorization", "Bearer "+adminToken) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("expected 200 OK for remove member, got %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("expected 200 OK for remove share, got %d: %s", rec.Code, rec.Body.String()) } - // 5. Test adding a team to a space + // 5. Test sharing list with a team team, err := store.CreateTeam("", "WG Team", "Haushalt") if err != nil { t.Fatalf("failed to create team: %v", err) } _ = store.AddTeamMember(team.ID, normalUser.ID, "member") - addTeamBody := []byte(`{"teamId":"` + team.ID + `","role":"editor"}`) - req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addTeamBody)) + addTeamBody := []byte(`{"targetType":"team","targetId":"` + team.ID + `","role":"editor"}`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addTeamBody)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated && rec.Code != http.StatusOK { - t.Fatalf("expected 201 or 200 for add space team, got %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("expected 201 or 200 for add list team share, got %d: %s", rec.Code, rec.Body.String()) } - // User should see the space again because their team was added - req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil) + // User should see the list again because their team was added + req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil) req.Header.Set("Authorization", "Bearer "+userToken) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("expected 200 OK for user spaces after team add, got %d", rec.Code) + t.Fatalf("expected 200 OK for user lists after team share add, got %d", rec.Code) } - userSpaces = nil - _ = json.Unmarshal(rec.Body.Bytes(), &userSpaces) + userLists = nil + _ = json.Unmarshal(rec.Body.Bytes(), &userLists) found = false - for _, s := range userSpaces { - if s.ID == createdSpace.ID { + for _, l := range userLists { + if l.ID == createdList.ID { found = true break } } if !found { - t.Fatalf("expected space %s to be visible to user via team, got %+v", createdSpace.ID, userSpaces) + t.Fatalf("expected list %s to be visible to user via team share, got %+v", createdList.ID, userLists) } } diff --git a/internal/domain/models.go b/internal/domain/models.go index 0921c86..e129ee8 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -324,40 +324,18 @@ type TagDetails struct { TotalMatches int `json:"totalMatches"` } -type SpaceMember struct { - SpaceID string `json:"spaceId"` - UserID string `json:"userId"` - DisplayName string `json:"displayName"` - Email string `json:"email"` - Role string `json:"role"` // owner | editor | viewer - JoinedAt string `json:"joinedAt"` -} - -type SpaceShare struct { +type ListShare struct { ID string `json:"id"` - SpaceID string `json:"spaceId"` + ListID string `json:"listId"` TargetType string `json:"targetType"` // team | user TargetID string `json:"targetId"` TargetName string `json:"targetName,omitempty"` + Email string `json:"email,omitempty"` Role string `json:"role"` // editor | viewer CreatedBy string `json:"createdBy"` CreatedAt string `json:"createdAt"` } -type Space struct { - ID string `json:"id"` - Name string `json:"name"` - Icon string `json:"icon"` - Description string `json:"description"` - InviteCode string `json:"inviteCode"` - IsPersonal bool `json:"isPersonal"` - OwnerID string `json:"ownerId"` - Members []SpaceMember `json:"members"` - Shares []SpaceShare `json:"shares,omitempty"` - ListsCount int `json:"listsCount"` - CreatedAt string `json:"createdAt"` -} - type ListItem struct { ID string `json:"id"` ListID string `json:"listId"` @@ -373,14 +351,18 @@ type ListItem struct { } type SharedList struct { - ID string `json:"id"` - SpaceID string `json:"spaceId"` - Title string `json:"title"` - Icon string `json:"icon"` - ListType string `json:"listType"` // shopping | checklist | vacation - Items []ListItem `json:"items"` - UpdatedAt string `json:"updatedAt"` - CreatedAt string `json:"createdAt"` + ID string `json:"id"` + UserID string `json:"userId"` + OwnerName string `json:"ownerName,omitempty"` + IsOwner bool `json:"isOwner"` + Role string `json:"role,omitempty"` // editor | viewer + Title string `json:"title"` + Icon string `json:"icon"` + ListType string `json:"listType"` // shopping | checklist | vacation + Items []ListItem `json:"items"` + Shares []ListShare `json:"shares,omitempty"` + UpdatedAt string `json:"updatedAt"` + CreatedAt string `json:"createdAt"` } type APIKey struct { @@ -397,8 +379,7 @@ type APIKey struct { type WSMessage struct { Event string `json:"event"` // ITEM_CREATED | ITEM_UPDATED | ITEM_TOGGLED | ITEM_DELETED | LIST_CLEARED | USER_PRESENCE - SpaceID string `json:"space_id"` - ListID string `json:"list_id,omitempty"` + ListID string `json:"list_id"` Payload interface{} `json:"payload"` Timestamp string `json:"timestamp"` } @@ -419,7 +400,6 @@ type FullExport struct { User User `json:"user"` Thoughts []Thought `json:"thoughts"` Documents []Document `json:"documents"` - Spaces []Space `json:"spaces"` Lists []SharedList `json:"lists"` TaskLists []TaskList `json:"taskLists,omitempty"` } diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 674ece8..5a04b26 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -137,28 +137,6 @@ func (s *Store) migrate() error { ); CREATE INDEX IF NOT EXISTS idx_task_lists_user ON task_lists(user_id, updated_at DESC); - CREATE TABLE IF NOT EXISTS spaces ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - icon TEXT NOT NULL DEFAULT '🏠', - description TEXT NOT NULL DEFAULT '', - invite_code TEXT UNIQUE NOT NULL, - is_personal INTEGER NOT NULL DEFAULT 0, - owner_id TEXT NOT NULL, - created_at TEXT NOT NULL, - FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE - ); - - CREATE TABLE IF NOT EXISTS space_members ( - space_id TEXT NOT NULL, - user_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'editor', - joined_at TEXT NOT NULL, - PRIMARY KEY (space_id, user_id), - FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE, - FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS organizations ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -259,16 +237,32 @@ func (s *Store) migrate() error { CREATE TABLE IF NOT EXISTS lists ( id TEXT PRIMARY KEY, - space_id TEXT NOT NULL, + user_id TEXT NOT NULL, title TEXT NOT NULL, icon TEXT NOT NULL DEFAULT '🛒', list_type TEXT NOT NULL DEFAULT 'shopping', updated_at TEXT NOT NULL, created_at TEXT NOT NULL, - FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); - CREATE INDEX IF NOT EXISTS idx_lists_space ON lists(space_id, updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_lists_user ON lists(user_id, updated_at DESC); + + CREATE TABLE IF NOT EXISTS list_shares ( + id TEXT PRIMARY KEY, + list_id TEXT NOT NULL, + target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), + target_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(list_id, target_type, target_id), + FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE, + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_list_shares_target ON list_shares(target_type, target_id); + CREATE INDEX IF NOT EXISTS idx_list_shares_list ON list_shares(list_id); CREATE TABLE IF NOT EXISTS list_items ( id TEXT PRIMARY KEY, @@ -342,14 +336,20 @@ func (s *Store) migrate() error { "CREATE INDEX IF NOT EXISTS idx_doc_team_shares_team ON doc_team_shares(team_id);", "CREATE INDEX IF NOT EXISTS idx_doc_team_shares_doc ON doc_team_shares(doc_id);", - "CREATE TABLE IF NOT EXISTS space_team_shares (id TEXT PRIMARY KEY, space_id TEXT NOT NULL, team_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'editor', created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(space_id, team_id));", - "CREATE INDEX IF NOT EXISTS idx_space_team_shares_team ON space_team_shares(team_id);", - "CREATE INDEX IF NOT EXISTS idx_space_team_shares_space ON space_team_shares(space_id);", + "ALTER TABLE lists ADD COLUMN user_id TEXT NOT NULL DEFAULT '';", + "CREATE TABLE IF NOT EXISTS list_shares (id TEXT PRIMARY KEY, list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(list_id, target_type, target_id));", + "CREATE INDEX IF NOT EXISTS idx_list_shares_target ON list_shares(target_type, target_id);", + "CREATE INDEX IF NOT EXISTS idx_list_shares_list ON list_shares(list_id);", } for _, q := range columnMigrations { _, _ = s.db.Exec(q) } + // Clean up legacy space_id from documents and task_lists if empty or orphaned + _, _ = s.db.Exec(`DROP TABLE IF EXISTS spaces`) + _, _ = s.db.Exec(`DROP TABLE IF EXISTS space_members`) + _, _ = s.db.Exec(`DROP TABLE IF EXISTS space_team_shares`) + // Backfill username for existing accounts where username is empty _, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`) @@ -442,13 +442,10 @@ func (s *Store) SeedStarterContent(user *domain.User) error { _, _ = s.UpdateThoughtTriage(user.ID, keptTask.ID, "keep", "task", "", now[:10], "") } - familySpace, err := s.CreateSpace(user.ID, "Familie & Zuhause", "🏡", "Geteilter Bereich für Einkäufe, Haushalt & Urlaubsplanung", false) - if err == nil && familySpace != nil { - shopList, err := s.CreateList(familySpace.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping") - if err == nil && shopList != nil { - _, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0) - _, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1) - } + shopList, err := s.CreateList(user.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping") + if err == nil && shopList != nil { + _, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0) + _, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1) } starterBlocks := []domain.DocumentBlock{ @@ -692,12 +689,8 @@ func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) { SELECT tm2.user_id FROM team_members tm1 JOIN team_members tm2 ON tm1.team_id = tm2.team_id WHERE tm1.user_id = ? - UNION - SELECT sm2.user_id FROM space_members sm1 - JOIN space_members sm2 ON sm1.space_id = sm2.space_id - WHERE sm1.user_id = ? )`, - viewerID, viewerID, viewerID, + viewerID, viewerID, ) if err != nil { return []domain.User{*viewer}, nil @@ -879,15 +872,14 @@ func (s *Store) accessibleTaskListIDsQuery() string { SELECT task_list_id FROM task_list_shares WHERE (target_type = 'user' AND target_id = ?) OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) - ) - OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))` + )` } func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) { row := s.db.QueryRow( `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`, - thoughtID, userID, userID, userID, userID, userID, userID, + thoughtID, userID, userID, userID, userID, userID, ) return scanThought(row) } @@ -940,7 +932,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er AND task_list_id = ? AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) ORDER BY is_completed ASC, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`, - userID, userID, userID, userID, userID, userID, taskListID, + userID, userID, userID, userID, userID, taskListID, ) } else { rows, err = s.db.Query( @@ -949,7 +941,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) ORDER BY is_completed ASC, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`, - userID, userID, userID, userID, userID, userID, + userID, userID, userID, userID, userID, ) } if err != nil { @@ -2190,339 +2182,9 @@ func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) { return users, nil } -// --- SPACES & REALTIME LISTS --- +// --- STANDALONE REALTIME LISTS & SHARING --- -func (s *Store) CreateSpace(ownerID, name, icon, description string, isPersonal bool) (*domain.Space, error) { - if strings.TrimSpace(name) == "" { - return nil, errors.New("space name required") - } - if icon == "" { - icon = "🏠" - } - now := domain.NowISO() - inviteCode := strings.ToUpper(domain.NewID("inv")[4:12]) - personalInt := 0 - if isPersonal { - personalInt = 1 - } - - sp := &domain.Space{ - ID: domain.NewID("spc"), - Name: strings.TrimSpace(name), - Icon: icon, - Description: strings.TrimSpace(description), - InviteCode: inviteCode, - IsPersonal: isPersonal, - OwnerID: ownerID, - CreatedAt: now, - } - - _, err := s.db.Exec( - `INSERT INTO spaces (id, name, icon, description, invite_code, is_personal, owner_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - sp.ID, sp.Name, sp.Icon, sp.Description, sp.InviteCode, personalInt, sp.OwnerID, sp.CreatedAt, - ) - if err != nil { - return nil, err - } - - _, _ = s.db.Exec( - `INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, 'owner', ?)`, - sp.ID, ownerID, now, - ) - return s.GetSpaceByID(sp.ID) -} - -func (s *Store) JoinSpaceByInviteCode(userID, inviteCode string) (*domain.Space, error) { - inviteCode = strings.ToUpper(strings.TrimSpace(inviteCode)) - var spaceID string - err := s.db.QueryRow(`SELECT id FROM spaces WHERE invite_code = ?`, inviteCode).Scan(&spaceID) - if err != nil { - return nil, errors.New("Ungültiger Einladungscode") - } - - _, err = s.db.Exec( - `INSERT OR IGNORE INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, 'editor', ?)`, - spaceID, userID, domain.NowISO(), - ) - if err != nil { - return nil, err - } - return s.GetSpaceByID(spaceID) -} - -func (s *Store) GetSpaceByID(spaceID string) (*domain.Space, error) { - sp := &domain.Space{} - var personalInt int - err := s.db.QueryRow( - `SELECT id, name, icon, description, invite_code, is_personal, owner_id, created_at FROM spaces WHERE id = ?`, - spaceID, - ).Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt) - if err != nil { - return nil, err - } - sp.IsPersonal = personalInt != 0 - sp.Members, _ = s.GetSpaceMembers(spaceID) - _ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaceID).Scan(&sp.ListsCount) - return sp, nil -} - -func (s *Store) GetSpaceMembers(spaceID string) ([]domain.SpaceMember, error) { - rows, err := s.db.Query( - `SELECT sm.space_id, sm.user_id, u.display_name, u.email, sm.role, sm.joined_at - FROM space_members sm - JOIN users u ON u.id = sm.user_id - WHERE sm.space_id = ? - ORDER BY sm.joined_at ASC`, - spaceID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - - members := make([]domain.SpaceMember, 0) - for rows.Next() { - var m domain.SpaceMember - if err := rows.Scan(&m.SpaceID, &m.UserID, &m.DisplayName, &m.Email, &m.Role, &m.JoinedAt); err == nil { - members = append(members, m) - } - } - return members, nil -} - -func (s *Store) AddSpaceMember(currentUserID, spaceID, targetUserIdentifier, role string) (*domain.SpaceMember, error) { - // 1. Check if current user is member of space - var myRole string - err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole) - if err != nil { - return nil, errors.New("keine Berechtigung für diesen Bereich") - } - - // 2. Resolve target user by ID, username or email - cleanTarget := strings.TrimSpace(targetUserIdentifier) - var targetUser *domain.User - if strings.HasPrefix(cleanTarget, "usr_") { - targetUser, _ = s.GetUserByID(cleanTarget) - } - if targetUser == nil { - targetUser, _ = s.GetUserByLogin(cleanTarget) - } - if targetUser == nil { - targetUser, err = s.GetUserByID(cleanTarget) - } - if err != nil || targetUser == nil { - return nil, errors.New("benutzer nicht gefunden") - } - - // Protect existing owner from role downgrade! - var existingRole string - _ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUser.ID).Scan(&existingRole) - if existingRole == "owner" { - role = "owner" - } else if role != "owner" && role != "editor" && role != "viewer" { - role = "editor" - } - - now := domain.NowISO() - _, err = s.db.Exec( - `INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, ?, ?) - ON CONFLICT(space_id, user_id) DO UPDATE SET role = excluded.role`, - spaceID, targetUser.ID, role, now, - ) - if err != nil { - return nil, err - } - - return &domain.SpaceMember{ - SpaceID: spaceID, - UserID: targetUser.ID, - DisplayName: targetUser.DisplayName, - Email: targetUser.Email, - Role: role, - JoinedAt: now, - }, nil -} - -func (s *Store) CreateSpaceTeamShare(currentUserID, spaceID, teamID, role string) (*domain.SpaceShare, error) { - var myRole string - err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole) - if err != nil || (myRole != "owner" && myRole != "editor") { - return nil, errors.New("keine Berechtigung für diesen Bereich") - } - - cleanTeamID := strings.TrimSpace(teamID) - var teamName string - err = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTeamID).Scan(&teamName) - if err != nil { - return nil, errors.New("team nicht gefunden") - } - - if role != "owner" && role != "editor" && role != "viewer" { - role = "editor" - } - - now := domain.NowISO() - shareID := domain.NewID("spshare") - _, err = s.db.Exec( - `INSERT INTO space_team_shares (id, space_id, team_id, role, created_at, created_by) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(space_id, team_id) DO UPDATE SET role = excluded.role`, - shareID, spaceID, cleanTeamID, role, now, currentUserID, - ) - if err != nil { - return nil, err - } - - return &domain.SpaceShare{ - ID: shareID, - SpaceID: spaceID, - TargetType: "team", - TargetID: cleanTeamID, - TargetName: teamName, - Role: role, - CreatedBy: currentUserID, - CreatedAt: now, - }, nil -} - -func (s *Store) GetSpaceShares(spaceID string) ([]domain.SpaceShare, error) { - shares := make([]domain.SpaceShare, 0) - - // 1. Team shares - tRows, err := s.db.Query( - `SELECT s.id, s.space_id, s.team_id, t.name, s.role, s.created_by, s.created_at - FROM space_team_shares s - JOIN teams t ON s.team_id = t.id - WHERE s.space_id = ? - ORDER BY s.created_at ASC`, - spaceID, - ) - if err == nil { - for tRows.Next() { - var sh domain.SpaceShare - if err := tRows.Scan(&sh.ID, &sh.SpaceID, &sh.TargetID, &sh.TargetName, &sh.Role, &sh.CreatedBy, &sh.CreatedAt); err == nil { - sh.TargetType = "team" - shares = append(shares, sh) - } - } - _ = tRows.Close() - } - - // 2. Individual invited members (non-owner members) - var ownerID string - _ = s.db.QueryRow(`SELECT owner_id FROM spaces WHERE id = ?`, spaceID).Scan(&ownerID) - - mRows, err := s.db.Query( - `SELECT sm.user_id, u.display_name, u.email, sm.role, sm.joined_at - FROM space_members sm - JOIN users u ON u.id = sm.user_id - WHERE sm.space_id = ? AND sm.user_id != ? - ORDER BY sm.joined_at ASC`, - spaceID, ownerID, - ) - if err == nil { - for mRows.Next() { - var uid, dName, email, role, joinedAt string - if err := mRows.Scan(&uid, &dName, &email, &role, &joinedAt); err == nil { - name := dName - if name == "" { - name = email - } - shares = append(shares, domain.SpaceShare{ - ID: "sm_" + uid, - SpaceID: spaceID, - TargetType: "user", - TargetID: uid, - TargetName: name, - Role: role, - CreatedAt: joinedAt, - }) - } - } - _ = mRows.Close() - } - - return shares, nil -} - -func (s *Store) RemoveSpaceTeamShare(currentUserID, spaceID, targetID string) error { - var myRole string - err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole) - if err != nil || (myRole != "owner" && myRole != "editor") { - return errors.New("keine Berechtigung zum Entfernen der Freigabe") - } - - cleanTarget := strings.TrimSpace(targetID) - _, err = s.db.Exec(`DELETE FROM space_team_shares WHERE space_id = ? AND (id = ? OR team_id = ?)`, spaceID, cleanTarget, cleanTarget) - return err -} - -func (s *Store) RemoveSpaceMember(currentUserID, spaceID, targetUserID string) error { - var myRole string - err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole) - if err != nil { - return errors.New("keine Berechtigung für diesen Bereich") - } - - cleanTarget := strings.TrimSpace(targetUserID) - if strings.HasPrefix(cleanTarget, "team_") || strings.HasPrefix(cleanTarget, "spshare_") { - return s.RemoveSpaceTeamShare(currentUserID, spaceID, cleanTarget) - } - - if currentUserID != cleanTarget && myRole != "owner" { - return errors.New("nur der Eigentümer kann andere Mitglieder entfernen") - } - - var targetRole string - _ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, cleanTarget).Scan(&targetRole) - if targetRole == "owner" { - var ownerCount int - _ = s.db.QueryRow(`SELECT COUNT(*) FROM space_members WHERE space_id = ? AND role = 'owner'`, spaceID).Scan(&ownerCount) - if ownerCount <= 1 { - return errors.New("der letzte Eigentümer kann den Bereich nicht verlassen") - } - } - - _, err = s.db.Exec(`DELETE FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, cleanTarget) - return err -} - -func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) { - rows, err := s.db.Query( - `SELECT s.id, s.name, s.icon, s.description, s.invite_code, s.is_personal, s.owner_id, s.created_at - FROM spaces s - LEFT JOIN space_members sm ON sm.space_id = s.id AND sm.user_id = ? - LEFT JOIN space_team_shares sts ON sts.space_id = s.id AND sts.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?) - WHERE sm.user_id = ? OR sts.team_id IS NOT NULL - GROUP BY s.id - ORDER BY s.created_at ASC`, - userID, userID, userID, - ) - if err != nil { - return nil, err - } - - spaces := make([]domain.Space, 0) - for rows.Next() { - var sp domain.Space - var personalInt int - if err := rows.Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt); err == nil { - sp.IsPersonal = personalInt != 0 - spaces = append(spaces, sp) - } - } - _ = rows.Close() - - for i := range spaces { - spaces[i].Members, _ = s.GetSpaceMembers(spaces[i].ID) - spaces[i].Shares, _ = s.GetSpaceShares(spaces[i].ID) - _ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaces[i].ID).Scan(&spaces[i].ListsCount) - } - return spaces, nil -} - -func (s *Store) CreateList(spaceID, title, icon, listType string) (*domain.SharedList, error) { +func (s *Store) CreateList(userID, title, icon, listType string) (*domain.SharedList, error) { if strings.TrimSpace(title) == "" { return nil, errors.New("list title required") } @@ -2535,59 +2197,167 @@ func (s *Store) CreateList(spaceID, title, icon, listType string) (*domain.Share now := domain.NowISO() lst := &domain.SharedList{ ID: domain.NewID("lst"), - SpaceID: spaceID, + UserID: userID, + IsOwner: true, + Role: "editor", Title: strings.TrimSpace(title), Icon: icon, ListType: listType, Items: []domain.ListItem{}, + Shares: []domain.ListShare{}, UpdatedAt: now, CreatedAt: now, } _, err := s.db.Exec( - `INSERT INTO lists (id, space_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, - lst.ID, lst.SpaceID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt, + `INSERT INTO lists (id, user_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + lst.ID, lst.UserID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt, ) if err != nil { return nil, err } + _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&lst.OwnerName) return lst, nil } -func (s *Store) GetListsForSpace(spaceID string) ([]domain.SharedList, error) { - rows, err := s.db.Query( - `SELECT id, space_id, title, icon, list_type, updated_at, created_at - FROM lists WHERE space_id = ? ORDER BY created_at ASC`, - spaceID, +func (s *Store) AddListShare(currentUserID, listID, targetType, targetID, role string) (*domain.ListShare, error) { + if targetType != "team" && targetType != "user" { + return nil, errors.New("ungültiger Freigabetyp") + } + targetID = strings.TrimSpace(targetID) + if targetID == "" { + return nil, errors.New("Ziel-ID erforderlich") + } + if role != "viewer" && role != "editor" { + role = "editor" + } + + if targetType == "user" { + var targetUser *domain.User + if strings.HasPrefix(targetID, "usr_") { + targetUser, _ = s.GetUserByID(targetID) + } + if targetUser == nil { + targetUser, _ = s.GetUserByLogin(targetID) + } + if targetUser == nil { + targetUser, _ = s.GetUserByEmail(targetID) + } + if targetUser == nil { + return nil, fmt.Errorf("benutzer nicht gefunden: %s", targetID) + } + targetID = targetUser.ID + } + + // Verify current user is owner or editor of the list + var ownerID string + err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID) + if err != nil { + return nil, errors.New("liste nicht gefunden") + } + if ownerID != currentUserID { + var myRole string + err = s.db.QueryRow(`SELECT role FROM list_shares WHERE list_id = ? AND target_type = 'user' AND target_id = ?`, listID, currentUserID).Scan(&myRole) + if err != nil || myRole != "editor" { + return nil, errors.New("keine Berechtigung zum Teilen dieser Liste") + } + } + + now := domain.NowISO() + share := &domain.ListShare{ + ID: domain.NewID("lsh"), + ListID: listID, + TargetType: targetType, + TargetID: targetID, + Role: role, + CreatedBy: currentUserID, + CreatedAt: now, + } + + _, err = s.db.Exec( + `INSERT INTO list_shares (id, list_id, target_type, target_id, role, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(list_id, target_type, target_id) DO UPDATE SET role = excluded.role`, + share.ID, share.ListID, share.TargetType, share.TargetID, share.Role, share.CreatedBy, share.CreatedAt, ) if err != nil { return nil, err } - lists := make([]domain.SharedList, 0) + _ = s.db.QueryRow(`SELECT id, created_at FROM list_shares WHERE list_id = ? AND target_type = ? AND target_id = ?`, + listID, targetType, targetID).Scan(&share.ID, &share.CreatedAt) + + if targetType == "team" { + _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName) + } else { + _ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, targetID).Scan(&share.TargetName, &share.Email) + } + return share, nil +} + +func (s *Store) RemoveListShare(currentUserID, listID, shareIDOrTargetID string) error { + var ownerID string + err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID) + if err != nil { + return errors.New("liste nicht gefunden") + } + if ownerID != currentUserID { + return errors.New("nur der Eigentümer kann Freigaben entfernen") + } + + cleanTarget := strings.TrimSpace(shareIDOrTargetID) + _, err = s.db.Exec( + `DELETE FROM list_shares WHERE list_id = ? AND (id = ? OR target_id = ?)`, + listID, cleanTarget, cleanTarget, + ) + return err +} + +func (s *Store) GetListShares(listID string) ([]domain.ListShare, error) { + rows, err := s.db.Query( + `SELECT id, list_id, target_type, target_id, role, created_by, created_at + FROM list_shares WHERE list_id = ? ORDER BY created_at ASC`, + listID, + ) + if err != nil { + return []domain.ListShare{}, err + } + defer rows.Close() + + shares := make([]domain.ListShare, 0) for rows.Next() { - var l domain.SharedList - if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil { - lists = append(lists, l) + var sh domain.ListShare + if err := rows.Scan(&sh.ID, &sh.ListID, &sh.TargetType, &sh.TargetID, &sh.Role, &sh.CreatedBy, &sh.CreatedAt); err == nil { + if sh.TargetType == "team" { + _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName) + } else { + _ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName, &sh.Email) + } + shares = append(shares, sh) } } - _ = rows.Close() - - for i := range lists { - lists[i].Items, _ = s.GetListItems(lists[i].ID) - } - return lists, nil + return shares, nil } func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) { rows, err := s.db.Query( - `SELECT DISTINCT l.id, l.space_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at + `SELECT DISTINCT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at, + u.display_name, + CASE + WHEN l.user_id = ? THEN 'owner' + WHEN ls.role IS NOT NULL THEN ls.role + ELSE 'editor' + END as effective_role FROM lists l - LEFT JOIN space_members sm ON sm.space_id = l.space_id AND sm.user_id = ? - LEFT JOIN space_team_shares sts ON sts.space_id = l.space_id AND sts.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?) - WHERE sm.user_id = ? OR sts.team_id IS NOT NULL + LEFT JOIN users u ON u.id = l.user_id + LEFT JOIN list_shares ls ON ls.list_id = l.id AND ( + (ls.target_type = 'user' AND ls.target_id = ?) + OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + ) + WHERE l.user_id = ? + OR ls.id IS NOT NULL ORDER BY l.updated_at DESC`, - userID, userID, userID, + userID, userID, userID, userID, ) if err != nil { return nil, err @@ -2596,7 +2366,12 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) { lists := make([]domain.SharedList, 0) for rows.Next() { var l domain.SharedList - if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil { + var ownerName sql.NullString + var effectiveRole string + if err := rows.Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole); err == nil { + l.OwnerName = ownerName.String + l.IsOwner = (l.UserID == userID) + l.Role = effectiveRole lists = append(lists, l) } } @@ -2604,25 +2379,68 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) { for i := range lists { lists[i].Items, _ = s.GetListItems(lists[i].ID) + lists[i].Shares, _ = s.GetListShares(lists[i].ID) } return lists, nil } -func (s *Store) GetListByID(listID string) (*domain.SharedList, error) { +func (s *Store) GetListByID(userID, listID string) (*domain.SharedList, error) { var l domain.SharedList - err := s.db.QueryRow( - `SELECT id, space_id, title, icon, list_type, updated_at, created_at FROM lists WHERE id = ?`, - listID, - ).Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt) + var ownerName sql.NullString + var effectiveRole string + + var query string + var args []interface{} + if userID == "" { + query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at, + u.display_name, 'owner' as effective_role + FROM lists l + LEFT JOIN users u ON u.id = l.user_id + WHERE l.id = ?` + args = []interface{}{listID} + } else { + query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at, + u.display_name, + CASE + WHEN l.user_id = ? THEN 'owner' + WHEN ls.role IS NOT NULL THEN ls.role + ELSE 'editor' + END as effective_role + FROM lists l + LEFT JOIN users u ON u.id = l.user_id + LEFT JOIN list_shares ls ON ls.list_id = l.id AND ( + (ls.target_type = 'user' AND ls.target_id = ?) + OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + ) + WHERE l.id = ? AND ( + l.user_id = ? + OR ls.id IS NOT NULL + )` + args = []interface{}{userID, userID, userID, listID, userID} + } + + err := s.db.QueryRow(query, args...).Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole) if err != nil { return nil, err } + l.OwnerName = ownerName.String + l.IsOwner = (userID == "" || l.UserID == userID) + l.Role = effectiveRole l.Items, _ = s.GetListItems(l.ID) + l.Shares, _ = s.GetListShares(l.ID) return &l, nil } -func (s *Store) DeleteList(listID string) error { - _, err := s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID) +func (s *Store) DeleteList(userID, listID string) error { + var ownerID string + err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID) + if err != nil { + return errors.New("liste nicht gefunden") + } + if ownerID != userID { + return errors.New("nur der Eigentümer kann die Liste löschen") + } + _, err = s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID) return err } @@ -2829,9 +2647,11 @@ func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error) _ = s.db.QueryRow(` SELECT COUNT(DISTINCT l.id) FROM lists l - LEFT JOIN space_members sm ON sm.space_id = l.space_id AND sm.user_id = ? - LEFT JOIN space_team_shares sts ON sts.space_id = l.space_id AND sts.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?) - WHERE sm.user_id = ? OR sts.team_id IS NOT NULL`, + LEFT JOIN list_shares ls ON ls.list_id = l.id AND ( + (ls.target_type = 'user' AND ls.target_id = ?) + OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + ) + WHERE l.user_id = ? OR ls.id IS NOT NULL`, userID, userID, userID).Scan(&st.ListsCount) // Count open (uncompleted) To-Dos across accessible workspace documents (owned or shared) @@ -2908,7 +2728,6 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) { } thoughts, _ := s.GetThoughts(userID, "all", 5000) docs, _ := s.GetDocuments(userID) - spaces, _ := s.GetSpacesForUser(userID) lists, _ := s.GetAllListsForUser(userID) taskLists, _ := s.GetTaskLists(userID) @@ -2918,7 +2737,6 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) { User: *user, Thoughts: thoughts, Documents: docs, - Spaces: spaces, Lists: lists, TaskLists: taskLists, }, nil @@ -3127,9 +2945,8 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) { WHERE (target_type = 'user' AND target_id = ?) OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) ) - OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)) ORDER BY updated_at DESC`, - userID, userID, userID, userID, userID, + userID, userID, userID, userID, ) if err != nil { return []domain.TaskList{}, err @@ -3192,9 +3009,8 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error) WHERE (target_type = 'user' AND target_id = ?) OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) ) - OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)) )`, - listID, userID, userID, userID, userID, userID, + listID, userID, userID, userID, userID, ).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt) if err != nil { return nil, err diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index c3c25b0..2eb8bcc 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -48,12 +48,8 @@ func TestStoreLifecycle(t *testing.T) { t.Fatalf("expected converted document, got status=%s type=%s ref=%s", triaged.Status, triaged.ConvertedType, triaged.ConvertedRefID) } - // 3. Test Shared Space & Shopping List - sp, err := store.CreateSpace(user.ID, "Test WG", "🍕", "Test Space", false) - if err != nil { - t.Fatalf("CreateSpace failed: %v", err) - } - lst, err := store.CreateList(sp.ID, "REWE Einkauf", "🛒", "shopping") + // 3. Test Standalone Shopping List & Sharing + lst, err := store.CreateList(user.ID, "REWE Einkauf", "🛒", "shopping") if err != nil { t.Fatalf("CreateList failed: %v", err) } @@ -66,26 +62,26 @@ func TestStoreLifecycle(t *testing.T) { t.Fatalf("ToggleListItem failed: %v", err) } - // Test AddSpaceMember & RemoveSpaceMember + // Test AddListShare & RemoveListShare u2, err := store.CreateUser("collab_space@example.com", "hash", "Collab User", "member") if err != nil { t.Fatalf("CreateUser u2 failed: %v", err) } - member, err := store.AddSpaceMember(user.ID, sp.ID, u2.ID, "editor") - if err != nil || member.UserID != u2.ID { - t.Fatalf("AddSpaceMember failed: %v", err) + share, err := store.AddListShare(user.ID, lst.ID, "user", u2.ID, "editor") + if err != nil || share.TargetID != u2.ID { + t.Fatalf("AddListShare failed: %v", err) } - members, err := store.GetSpaceMembers(sp.ID) - if err != nil || len(members) != 2 { - t.Fatalf("expected 2 members, got %d", len(members)) + shares, err := store.GetListShares(lst.ID) + if err != nil || len(shares) != 1 { + t.Fatalf("expected 1 share, got %d", len(shares)) } - err = store.RemoveSpaceMember(user.ID, sp.ID, u2.ID) + err = store.RemoveListShare(user.ID, lst.ID, share.ID) if err != nil { - t.Fatalf("RemoveSpaceMember failed: %v", err) + t.Fatalf("RemoveListShare failed: %v", err) } - membersAfter, _ := store.GetSpaceMembers(sp.ID) - if len(membersAfter) != 1 { - t.Fatalf("expected 1 member after remove, got %d", len(membersAfter)) + sharesAfter, _ := store.GetListShares(lst.ID) + if len(sharesAfter) != 0 { + t.Fatalf("expected 0 shares after remove, got %d", len(sharesAfter)) } // 4. Test Webhook API Key generation & validation @@ -99,7 +95,7 @@ func TestStoreLifecycle(t *testing.T) { } // 5. Test TaskLists CRUD - taskList, err := store.CreateTaskList(user.ID, sp.ID, "", "Haus & Garten", "🏡", "#10b981") + taskList, err := store.CreateTaskList(user.ID, "", "", "Haus & Garten", "🏡", "#10b981") if err != nil || taskList.ID == "" { t.Fatalf("CreateTaskList failed: %v", err) } @@ -160,8 +156,8 @@ func TestStoreLifecycle(t *testing.T) { } // Share with user2 as "read" - share, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read") - if err != nil || share.Permission != "read" { + docShare, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read") + if err != nil || docShare.Permission != "read" { t.Fatalf("CreateDocShare failed: %v", err) } diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 11000f9..095c2aa 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -22,7 +22,7 @@ var upgrader = websocket.Upgrader{ type Client struct { hub *Hub conn *websocket.Conn - spaceID string + roomID string userID string send chan []byte } @@ -37,7 +37,7 @@ type docLockItem struct { type Hub struct { mu sync.RWMutex - spaceRooms map[string]map[*Client]bool + rooms map[string]map[*Client]bool userRooms map[string]map[*Client]bool allClients map[*Client]bool locks map[string]*docLockItem @@ -45,7 +45,7 @@ type Hub struct { func NewHub() *Hub { h := &Hub{ - spaceRooms: make(map[string]map[*Client]bool), + rooms: make(map[string]map[*Client]bool), userRooms: make(map[string]map[*Client]bool), allClients: make(map[*Client]bool), locks: make(map[string]*docLockItem), @@ -57,11 +57,11 @@ func NewHub() *Hub { func (h *Hub) Register(c *Client) { h.mu.Lock() h.allClients[c] = true - if c.spaceID != "" { - if h.spaceRooms[c.spaceID] == nil { - h.spaceRooms[c.spaceID] = make(map[*Client]bool) + if c.roomID != "" { + if h.rooms[c.roomID] == nil { + h.rooms[c.roomID] = make(map[*Client]bool) } - h.spaceRooms[c.spaceID][c] = true + h.rooms[c.roomID][c] = true } if c.userID != "" && c.userID != "anonymous" { if h.userRooms[c.userID] == nil { @@ -72,8 +72,8 @@ func (h *Hub) Register(c *Client) { onlineCount := len(h.allClients) h.mu.Unlock() - if c.spaceID != "" { - h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{ + if c.roomID != "" { + h.Broadcast(c.roomID, "", "USER_PRESENCE", map[string]interface{}{ "onlineCount": onlineCount, }) } @@ -82,11 +82,11 @@ func (h *Hub) Register(c *Client) { func (h *Hub) Unregister(c *Client) { h.mu.Lock() delete(h.allClients, c) - if c.spaceID != "" { - if room, ok := h.spaceRooms[c.spaceID]; ok { + if c.roomID != "" { + if room, ok := h.rooms[c.roomID]; ok { delete(room, c) if len(room) == 0 { - delete(h.spaceRooms, c.spaceID) + delete(h.rooms, c.roomID) } } } @@ -297,7 +297,6 @@ func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) { func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) { msg := domain.WSMessage{ Event: event, - SpaceID: spaceID, ListID: listID, Payload: payload, Timestamp: domain.NowISO(), @@ -310,20 +309,27 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) { h.mu.RLock() defer h.mu.RUnlock() - room, ok := h.spaceRooms[spaceID] - if !ok { - return + targetRoom := listID + if targetRoom == "" { + targetRoom = spaceID } - for client := range room { - select { - case client.send <- data: - default: - // Slow client buffer full + + if room, ok := h.rooms[targetRoom]; ok { + for client := range room { + select { + case client.send <- data: + default: + // Slow client buffer full + } } } } -func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID string) { +func (h *Hub) BroadcastListEvent(listID, event string, payload interface{}) { + h.Broadcast("", listID, event, payload) +} + +func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, roomID, userID string) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Printf("[ws] upgrade error: %v", err) @@ -333,7 +339,7 @@ func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID st client := &Client{ hub: h, conn: conn, - spaceID: spaceID, + roomID: roomID, userID: userID, send: make(chan []byte, 128), }