diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dc6f142..a6c499f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -719,6 +719,7 @@ export const App: React.FC = () => { {activeTab === 'tasks' && ( void; + placeholder?: string; + disabled?: boolean; + excludeUserIds?: string[]; + className?: string; +} + +export const UserAutocomplete: React.FC = ({ + users, + selectedUser, + onSelectUser, + placeholder = 'Benutzer suchen (Name, @Benutzername oder E-Mail)…', + disabled = false, + excludeUserIds = [], + className = '', +}) => { + const [query, setQuery] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const containerRef = useRef(null); + const inputRef = useRef(null); + + // Close dropdown on outside click + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Filter users based on query and exclusions + const filteredUsers = React.useMemo(() => { + const q = query.trim().toLowerCase(); + const available = users.filter((u) => !excludeUserIds.includes(u.id)); + if (!q) { + return available.slice(0, 15); + } + return available + .filter((u) => { + const nameMatch = (u.displayName || '').toLowerCase().includes(q); + const emailMatch = (u.email || '').toLowerCase().includes(q); + const userMatch = (u.username || '').toLowerCase().includes(q); + return nameMatch || emailMatch || userMatch; + }) + .slice(0, 15); + }, [users, query, excludeUserIds]); + + const handleSelect = (user: User) => { + onSelectUser(user); + setQuery(''); + setIsOpen(false); + setHighlightedIndex(-1); + }; + + const handleClear = () => { + onSelectUser(null); + setQuery(''); + setTimeout(() => inputRef.current?.focus(), 0); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen) { + if (e.key === 'ArrowDown' || e.key === 'Enter') { + setIsOpen(true); + } + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlightedIndex((prev) => + prev < filteredUsers.length - 1 ? prev + 1 : 0 + ); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredUsers.length - 1 + ); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (highlightedIndex >= 0 && highlightedIndex < filteredUsers.length) { + handleSelect(filteredUsers[highlightedIndex]); + } + } else if (e.key === 'Escape') { + setIsOpen(false); + } + }; + + return ( +
+ {selectedUser ? ( +
+
+
+ {(selectedUser.displayName || selectedUser.email || '?')[0].toUpperCase()} +
+ + {selectedUser.displayName || selectedUser.email} + + {selectedUser.username && ( + + @{selectedUser.username} + + )} + {selectedUser.email && selectedUser.displayName && ( + + ({selectedUser.email}) + + )} +
+ {!disabled && ( + + )} +
+ ) : ( +
+ + { + setQuery(e.target.value); + setIsOpen(true); + setHighlightedIndex(-1); + }} + onFocus={() => setIsOpen(true)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="w-full h-8 pl-8 pr-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs placeholder:text-muted transition-colors disabled:opacity-50" + /> + + {isOpen && ( +
+ {filteredUsers.length === 0 ? ( +
+ Kein passender Benutzer gefunden +
+ ) : ( + filteredUsers.map((u, idx) => { + const isHighlighted = idx === highlightedIndex; + return ( + + ); + }) + )} +
+ )} +
+ )} +
+ ); +}; diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 52202da..cedcb58 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -8,12 +8,14 @@ import { Check, Radio, Sparkles, + Share, Share2, User as UserIcon, X, } from 'lucide-react'; import { ListItem, SharedList, Space, Team, User, WSMessage } from '../../lib/types'; import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api'; +import { UserAutocomplete } from '../UserAutocomplete'; interface SharedListsViewProps { onRefreshGlobal: () => void; @@ -87,6 +89,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba 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); @@ -291,6 +294,26 @@ export const SharedListsView: React.FC = ({ onRefreshGloba setActiveSpace(created); }; + const openShareModal = async () => { + if (!activeSpace) 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([]), + ]); + setCollaborators(usersList); + setTeams(teamsList); + setActiveSpace((prev) => (prev ? { ...prev, shares: sharesList } : prev)); + } catch { + // ignore + } + }; + const handleAddShare = async (e: React.FormEvent) => { e.preventDefault(); if (!activeSpace) return; @@ -309,25 +332,27 @@ export const SharedListsView: React.FC = ({ onRefreshGloba }); setSelectedTeamId(''); } else { - const target = inviteTarget.trim(); - if (!target) { - setShareError('Bitte einen Benutzer auswählen oder eingeben'); + if (!selectedShareUser) { + setShareError('Bitte einen bestehenden Benutzer auswählen'); setInvitingLoading(false); return; } - const isId = collaborators.some((u) => u.id === target); await api.addSpaceMember(activeSpace.id, { - userId: isId ? target : undefined, - email: !isId ? target : undefined, + userId: selectedShareUser.id, role: inviteRole, }); - setInviteTarget(''); + setSelectedShareUser(null); } - const sp = await api.getSpaces(); + 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); + if (updated) { + setActiveSpace({ ...updated, shares: freshShares }); + } onRefreshGlobal(); } catch (err: any) { setShareError(err.message || 'Freigabe konnte nicht hinzugefügt werden'); @@ -336,34 +361,25 @@ export const SharedListsView: React.FC = ({ onRefreshGloba } }; - const handleRemoveMember = async (targetUserId: string) => { + const handleRemoveMember = async (targetId: string) => { if (!activeSpace) return; - if (!window.confirm('Dieses Mitglied wirklich aus der Einkaufsliste / dem Bereich entfernen?')) return; + if (!window.confirm('Freigabe wirklich aufheben?')) return; setInvitingLoading(true); setShareError(null); try { - await api.removeSpaceMember(activeSpace.id, targetUserId); - setActiveSpace((prev) => - prev - ? { - ...prev, - members: (prev.members || []).filter((m) => m.userId !== targetUserId), - } - : prev - ); - setSpaces((prev) => - prev.map((s) => - s.id === activeSpace.id - ? { - ...s, - members: (s.members || []).filter((m) => m.userId !== targetUserId), - } - : s - ) - ); + 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 || 'Mitglied konnte nicht entfernt werden'); + setShareError(err.message || 'Freigabe konnte nicht entfernt werden'); } finally { setInvitingLoading(false); } @@ -414,17 +430,18 @@ export const SharedListsView: React.FC = ({ onRefreshGloba return (
- {/* Top Space Bar & Realtime Indicator */} -
-
+ {/* Spaces Bar */} +
+
+ Bereich: {spaces.map((sp) => (
- - {/* Realtime Status & Share List Button */} - {activeSpace && ( -
-
- -
- - -
- )}
{/* Create Space Modal */} @@ -501,39 +483,103 @@ export const SharedListsView: React.FC = ({ onRefreshGloba )} - {/* List Tabs within Active Space */} -
- {lists.map((lst) => { - const openCount = lst.items.filter((i) => !i.isCompleted).length; - const isSelected = activeList?.id === lst.id; - return ( - - ); - })} + {/* Top Header: List Tabs & Actions */} +
+ {/* List Tabs */} +
+ {lists.map((lst) => { + const openCount = lst.items.filter((i) => !i.isCompleted).length; + const isSelected = activeList?.id === lst.id; + return ( + + ); + })} - + +
+ + {/* 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); + + return ( +
+ {isShared && ( + isOwner ? ( + + ) : ( + + ) + )} + + +
+ ); + })()} + + {/* Live Sync Status */} +
+ +
+
{showNewListModal && ( @@ -819,45 +865,31 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
) : ( -
-
- - -
- - {(!inviteTarget || !collaborators.some((u) => u.id === inviteTarget)) && ( - setInviteTarget(e.target.value)} - className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full" +
+
+ m.userId), + ...(activeSpace.shares || []) + .filter((s) => s.targetType === 'user') + .map((s) => s.targetId), + ].filter(Boolean)} + disabled={invitingLoading} + placeholder="Benutzer suchen (Name oder @Benutzername)…" /> - )} +
+
)} @@ -866,7 +898,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba type="submit" disabled={ invitingLoading || - (addShareType === 'team' ? !selectedTeamId : !inviteTarget.trim()) + (addShareType === 'team' ? !selectedTeamId : !selectedShareUser) } className="btn-primary text-xs h-8 px-4 flex items-center gap-1.5" > @@ -882,12 +914,60 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
- {/* Current Space Members List */} + {/* Current Space Members & Team Shares List */}
- Aktive Freigaben & Mitglieder ({activeSpace.members?.length || 1}) + Aktive Freigaben & Mitglieder ( + {((activeSpace.shares || []).filter((s) => s.targetType === 'team').length) + + (activeSpace.members?.length || 1)} + )
-
+
+ {/* Team Shares */} + {(activeSpace.shares || []) + .filter((s) => s.targetType === 'team') + .map((sh) => { + const canRemove = + activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin'; + return ( +
+
+
+ +
+
+
+ {sh.targetName || 'Team'} + Gruppe +
+
Freigabe für die Gruppe
+
+
+ +
+ + {sh.role === 'editor' ? 'Bearbeiter' : 'Leser'} + + {canRemove && ( + + )} +
+
+ ); + })} + + {/* Individual Members */} {(activeSpace.members || []).map((m) => { const isOwner = m.role === 'owner' || m.userId === activeSpace.ownerId; const isMe = m.userId === currentUser?.id; diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index ce684fe..7476ff5 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -13,6 +13,7 @@ import { Edit3, X, Layers, + Share, Share2, Users, CheckSquare, @@ -29,6 +30,7 @@ import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn } f import { api, triggerHaptic } from '../../lib/api'; import { TaskDetailModal } from './TaskDetailModal'; import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal'; +import { UserAutocomplete } from '../UserAutocomplete'; const getInitials = (name?: string): string => { if (!name) return '?'; @@ -40,12 +42,14 @@ const getInitials = (name?: string): string => { }; interface TasksViewProps { + currentUser?: User | null; onRefreshStats: () => void; onOpenDocument: (docId: string) => void; refreshTrigger?: number; } export const TasksView: React.FC = ({ + currentUser, onRefreshStats, onOpenDocument, refreshTrigger = 0, @@ -75,6 +79,7 @@ export const TasksView: React.FC = ({ const [sharingTaskList, setSharingTaskList] = useState(null); const [addShareType, setAddShareType] = useState<'team' | 'user'>('team'); const [selectedShareTargetId, setSelectedShareTargetId] = useState(''); + const [selectedShareUser, setSelectedShareUser] = useState(null); const [shareLoading, setShareLoading] = useState(false); // Detail modal state @@ -577,26 +582,47 @@ export const TasksView: React.FC = ({ } }; - const handleOpenShareModal = (tl: TaskList) => { + const handleOpenShareModal = async (tl: TaskList) => { setSharingTaskList(tl); setSelectedShareTargetId(''); + setSelectedShareUser(null); setShowShareListModal(true); + try { + const [freshTeams, freshUsers, freshShares] = await Promise.all([ + api.getUserTeams().catch(() => []), + api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []), + api.getTaskListShares(tl.id).catch(() => []), + ]); + setTeams(freshTeams); + setCollaborators(freshUsers); + setSharingTaskList((prev) => (prev && prev.id === tl.id ? { ...prev, shares: freshShares } : prev)); + } catch { + // ignore + } }; const handleAddShare = async () => { - if (!sharingTaskList || !selectedShareTargetId) return; + if (!sharingTaskList) return; + const targetId = addShareType === 'team' ? selectedShareTargetId : selectedShareUser?.id; + if (!targetId) return; setShareLoading(true); triggerHaptic(15); try { await api.addTaskListShare(sharingTaskList.id, { targetType: addShareType, - targetId: selectedShareTargetId, + targetId: targetId, }); - const freshLists = await api.getTaskLists(); + const [freshLists, freshShares] = await Promise.all([ + api.getTaskLists(), + api.getTaskListShares(sharingTaskList.id).catch(() => []), + ]); setTaskLists(freshLists); const updated = freshLists.find((l) => l.id === sharingTaskList.id); - if (updated) setSharingTaskList(updated); + if (updated) { + setSharingTaskList({ ...updated, shares: freshShares }); + } setSelectedShareTargetId(''); + setSelectedShareUser(null); await loadData(); } catch (err: any) { alert(err.message || 'Fehler beim Hinzufügen der Freigabe'); @@ -607,14 +633,20 @@ export const TasksView: React.FC = ({ const handleRemoveShare = async (shareId: string) => { if (!sharingTaskList) return; + if (!window.confirm('Freigabe wirklich aufheben?')) return; setShareLoading(true); triggerHaptic(15); try { await api.removeTaskListShare(sharingTaskList.id, shareId); - const freshLists = await api.getTaskLists(); + const [freshLists, freshShares] = await Promise.all([ + api.getTaskLists(), + api.getTaskListShares(sharingTaskList.id).catch(() => []), + ]); setTaskLists(freshLists); const updated = freshLists.find((l) => l.id === sharingTaskList.id); - if (updated) setSharingTaskList(updated); + if (updated) { + setSharingTaskList({ ...updated, shares: freshShares }); + } await loadData(); } catch (err: any) { alert(err.message || 'Fehler beim Entfernen der Freigabe'); @@ -869,19 +901,27 @@ export const TasksView: React.FC = ({ {tl.tasksCount !== undefined && tl.tasksCount > 0 && ( {tl.tasksCount} )} - {tl.members && tl.members.length > 0 ? ( -
- {tl.members.slice(0, 2).map((m) => ( - - {getInitials(m.displayName || m.email)[0]} - - ))} -
- ) : (tl.teamId || (tl.shares && tl.shares.length > 0)) ? ( - - - - ) : null} + {(() => { + const isShared = (tl.shares && tl.shares.length > 0) || Boolean(tl.teamId) || (tl.members && tl.members.length > 1); + if (!isShared) return null; + const otherMembers = (tl.members || []).filter((m) => m.userId !== currentUser?.id); + if (otherMembers.length > 0) { + return ( +
+ {otherMembers.slice(0, 2).map((m) => ( + + {getInitials(m.displayName || m.email)[0]} + + ))} +
+ ); + } + return ( + + + + ); + })()} {selectedListId === tl.id && (
- {/* Right Header Section: Sharing, Avatar Stack & View Switcher */} + {/* Right Header Section: Sharing Badge, Icon-Only Button & View Switcher */}
- {currentSelectedList && ( -
- + {currentSelectedList && (() => { + const isOwner = !currentSelectedList.ownerId || currentSelectedList.ownerId === currentUser?.id; + const sharesCount = (currentSelectedList.shares?.length || 0) > 0 + ? currentSelectedList.shares.length + : (currentSelectedList.members?.filter((m) => m.userId !== currentSelectedList.ownerId).length || 0); + const isShared = sharesCount > 0 || Boolean(currentSelectedList.teamId) || ((currentSelectedList.members?.length || 0) > 1); - {/* Avatar Stack */} - {currentSelectedList.members && currentSelectedList.members.length > 0 ? ( -
handleOpenShareModal(currentSelectedList)} - className="flex items-center -space-x-1.5 cursor-pointer hover:opacity-90 transition-opacity pl-0.5" - title={`Geteilt mit ${currentSelectedList.members.length} Mitgliedern (${currentSelectedList.teamName || 'Team'}). Klick zum Verwalten.`} - > - {currentSelectedList.members.slice(0, 3).map((m) => ( -
+ {isShared && ( + isOwner ? ( +
- ))} - {currentSelectedList.members.length > 3 && ( -
- +{currentSelectedList.members.length - 3} -
- )} -
- ) : ( -
+ Geteilt ({sharesCount > 0 ? sharesCount : currentSelectedList.members?.length || 1}) + + ) : ( + + ) + )} + +
- )} -
- )} + + +
+ ); + })()} {/* View Switcher: List vs Kanban */}
@@ -1291,7 +1328,7 @@ export const TasksView: React.FC = ({ type="button" onClick={() => { setAddShareType('team'); - setSelectedShareTargetId(''); + setSelectedShareUser(null); }} className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${ addShareType === 'team' @@ -1336,26 +1373,27 @@ export const TasksView: React.FC = ({ ))} ) : ( - +
+ s.targetType === 'user') + .map((s) => s.targetId), + ].filter(Boolean)} + disabled={shareLoading} + placeholder="Benutzer suchen (Name oder @Benutzername)…" + /> +
)}
) : ( -
-
- - -
- - {/* Direct Email fallback if user not in list */} - {(!shareTargetEmailOrId || !collaborators.some((u) => u.id === shareTargetEmailOrId)) && ( - setShareTargetEmailOrId(e.target.value)} - className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full" +
+
+ s.targetId || s.userId).filter(Boolean), + ]} + disabled={sharingLoading} + placeholder="Benutzer suchen (Name oder @Benutzername)…" /> - )} +
+
)} @@ -4500,7 +4486,7 @@ export const WorkspaceView: React.FC = ({ type="submit" disabled={ sharingLoading || - (addShareType === 'team' ? !selectedShareTeamId : !shareTargetEmailOrId.trim()) + (addShareType === 'team' ? !selectedShareTeamId : !selectedShareUser) } className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5" > @@ -4525,43 +4511,68 @@ export const WorkspaceView: React.FC = ({
) : (
- {docShares.map((share) => ( -
-
-
- {share.displayName || share.userEmail || share.userId} + {docShares.map((share) => { + const isTeam = share.targetType === 'team' || Boolean(share.teamId) || String(share.targetId || '').startsWith('team_'); + const targetKey = share.targetId || share.userId || share.id; + return ( +
+
+ {isTeam ? ( +
+ +
+ ) : ( +
+ {(share.displayName || share.userEmail || share.targetName || '?')[0].toUpperCase()} +
+ )} +
+
+ {share.displayName || share.targetName || share.userEmail || share.userId} + {isTeam && ( + Gruppe + )} +
+ {!isTeam && share.userEmail && share.displayName && ( +
{share.userEmail}
+ )} +
- {share.userEmail && share.displayName && ( -
{share.userEmail}
- )} -
-
- +
+ {!isTeam && ( + + )} + {isTeam && ( + + {share.permission === 'write' ? 'Schreiben' : 'Nur Lesen'} + + )} - + +
-
- ))} + ); + })}
)}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d855086..fa35be1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -589,9 +589,15 @@ export const api = { return ensureArray(res).map((s) => ({ ...s, members: ensureArray(s.members), + shares: ensureArray(s.shares), })); }, + async getSpaceShares(spaceId: string): Promise { + const res = await request(`/api/v1/spaces/${spaceId}/shares`); + return ensureArray(res); + }, + async createSpace(name: string, icon: string, description: string): Promise { return request('/api/v1/spaces', { method: 'POST', @@ -606,8 +612,8 @@ export const api = { }); }, - async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; teamId?: string; role?: string }): Promise { - return request(`/api/v1/spaces/${spaceId}/members`, { + async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; teamId?: string; role?: string }): Promise { + return request(`/api/v1/spaces/${spaceId}/members`, { method: 'POST', body: JSON.stringify(target), }); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index bb60b91..da80952 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -162,7 +162,10 @@ export interface DocumentBlock { export interface DocShare { id: string; docId: string; - userId: string; + targetType?: 'user' | 'team'; + targetId?: string; + targetName?: string; + userId?: string; userEmail?: string; displayName?: string; permission: 'read' | 'write'; @@ -238,6 +241,17 @@ export interface SpaceMember { joinedAt: string; } +export interface SpaceShare { + id: string; + spaceId: string; + targetType: 'team' | 'user'; + targetId: string; + targetName?: string; + role: 'editor' | 'viewer'; + createdBy?: string; + createdAt: string; +} + export interface Space { id: string; name: string; @@ -247,6 +261,7 @@ export interface Space { isPersonal: boolean; ownerId: string; members?: SpaceMember[]; + shares?: SpaceShare[]; listsCount: number; createdAt: string; } diff --git a/internal/api/server.go b/internal/api/server.go index e514d47..4028973 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -129,6 +129,7 @@ func (s *Server) Router() http.Handler { 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) @@ -953,28 +954,17 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) { } if req.TeamID != "" { - members, err := s.store.GetTeamMembers(req.TeamID) + share, err := s.store.CreateDocTeamShare(userID, docID, req.TeamID, req.Permission) if err != nil { - writeError(w, http.StatusBadRequest, "Team nicht gefunden") + writeError(w, http.StatusBadRequest, err.Error()) return } - var lastShare *domain.DocShare + members, _ := s.store.GetTeamMembers(req.TeamID) for _, tm := range members { - if tm.UserID == userID { - continue - } - share, err := s.store.CreateDocShare(userID, docID, tm.UserID, req.Permission) - if err == nil && share != nil { - lastShare = share - s.wsHub.BroadcastToUser(share.UserID, "DOC_SHARED", share) - s.wsHub.BroadcastDocEvent(docID, "DOC_SHARED", share) - } - } - if lastShare != nil { - writeJSON(w, http.StatusCreated, lastShare) - } else { - writeJSON(w, http.StatusOK, map[string]bool{"success": true}) + s.wsHub.BroadcastToUser(tm.UserID, "DOC_SHARED", share) } + s.wsHub.BroadcastDocEvent(docID, "DOC_SHARED", share) + writeJSON(w, http.StatusCreated, share) return } @@ -1153,25 +1143,17 @@ func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) { } if req.TeamID != "" { - members, err := s.store.GetTeamMembers(req.TeamID) + share, err := s.store.CreateSpaceTeamShare(currentUserID, spaceID, req.TeamID, req.Role) if err != nil { - writeError(w, http.StatusBadRequest, "Team nicht gefunden") + writeError(w, http.StatusBadRequest, err.Error()) return } - var lastMember *domain.SpaceMember + members, _ := s.store.GetTeamMembers(req.TeamID) for _, tm := range members { - member, err := s.store.AddSpaceMember(currentUserID, spaceID, tm.UserID, req.Role) - if err == nil && member != nil { - lastMember = member - s.wsHub.BroadcastToUser(member.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID}) - s.wsHub.Broadcast(spaceID, "", "MEMBER_ADDED", member) - } - } - if lastMember != nil { - writeJSON(w, http.StatusCreated, lastMember) - } else { - writeJSON(w, http.StatusOK, map[string]bool{"success": true}) + 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 } @@ -1196,18 +1178,31 @@ func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) { 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 err := s.store.RemoveSpaceMember(currentUserID, spaceID, targetUserID); err != nil { + // 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(targetUserID, "SPACE_LEFT", map[string]interface{}{"spaceId": spaceID}) - s.wsHub.Broadcast(spaceID, "", "MEMBER_REMOVED", map[string]interface{}{"spaceId": spaceID, "userId": targetUserID}) + 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}) } diff --git a/internal/domain/models.go b/internal/domain/models.go index b4cc515..2bab8ef 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -254,7 +254,10 @@ type DocumentBlock struct { type DocShare struct { ID string `json:"id"` DocID string `json:"docId"` - UserID string `json:"userId"` + TargetType string `json:"targetType,omitempty"` // user | team + TargetID string `json:"targetId,omitempty"` + TargetName string `json:"targetName,omitempty"` + UserID string `json:"userId,omitempty"` UserEmail string `json:"userEmail,omitempty"` DisplayName string `json:"displayName,omitempty"` Permission string `json:"permission"` // read | write @@ -330,6 +333,17 @@ type SpaceMember struct { JoinedAt string `json:"joinedAt"` } +type SpaceShare struct { + ID string `json:"id"` + SpaceID string `json:"spaceId"` + TargetType string `json:"targetType"` // team | user + TargetID string `json:"targetId"` + TargetName string `json:"targetName,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"` @@ -339,6 +353,7 @@ type Space struct { 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"` } diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 9a3f2a9..0bc22ad 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -337,6 +337,14 @@ func (s *Store) migrate() error { "CREATE TABLE IF NOT EXISTS task_list_shares (id TEXT PRIMARY KEY, task_list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(task_list_id, target_type, target_id));", "CREATE INDEX IF NOT EXISTS idx_task_list_shares_target ON task_list_shares(target_type, target_id);", "CREATE INDEX IF NOT EXISTS idx_task_list_shares_list ON task_list_shares(task_list_id);", + + "CREATE TABLE IF NOT EXISTS doc_team_shares (id TEXT PRIMARY KEY, doc_id TEXT NOT NULL, team_id TEXT NOT NULL, permission TEXT NOT NULL CHECK(permission IN ('read', 'write')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(doc_id, team_id));", + "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);", } for _, q := range columnMigrations { _, _ = s.db.Exec(q) @@ -1331,19 +1339,26 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) { } _ = rows.Close() - // 2. Fetch documents shared with the user (with per-user is_pinned from doc_pins) + // 2. Fetch documents shared with the user directly or via teams (with per-user is_pinned from doc_pins) sharedRows, err := s.db.Query( `SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path, CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned, d.is_archived, d.created_at, d.updated_at, - s.permission, u.display_name + COALESCE(s.permission, dts.permission, 'read') AS permission, u.display_name FROM documents d - JOIN doc_shares s ON d.id = s.doc_id JOIN users u ON d.user_id = u.id + LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ? + LEFT JOIN ( + SELECT dts2.doc_id, dts2.permission + FROM doc_team_shares dts2 + JOIN team_members tm ON tm.team_id = dts2.team_id + WHERE tm.user_id = ? + ) dts ON d.id = dts.doc_id LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ? - WHERE s.user_id = ? AND d.is_archived = 0 + WHERE (s.user_id = ? OR dts.doc_id IS NOT NULL) AND d.is_archived = 0 AND d.user_id != ? + GROUP BY d.id ORDER BY is_pinned DESC, d.updated_at DESC`, - userID, userID, + userID, userID, userID, userID, userID, ) if err == nil { for sharedRows.Next() { @@ -1404,18 +1419,25 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) return doc, nil } - // Try shared document (with per-user is_pinned) + // Try shared document (direct or team share, with per-user is_pinned) sharedRow := s.db.QueryRow( `SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path, CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned, d.is_archived, d.created_at, d.updated_at, - s.permission, u.display_name + COALESCE(s.permission, dts.permission, 'read') AS permission, u.display_name FROM documents d - JOIN doc_shares s ON d.id = s.doc_id JOIN users u ON d.user_id = u.id + LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ? + LEFT JOIN ( + SELECT dts2.doc_id, dts2.permission + FROM doc_team_shares dts2 + JOIN team_members tm ON tm.team_id = dts2.team_id + WHERE tm.user_id = ? + ) dts ON d.id = dts.doc_id LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ? - WHERE d.id = ? AND s.user_id = ?`, - userID, docID, userID, + WHERE d.id = ? AND (s.user_id = ? OR dts.doc_id IS NOT NULL) + LIMIT 1`, + userID, userID, userID, docID, userID, ) d := &domain.Document{} var blocksStr, tagsStr string @@ -1630,11 +1652,17 @@ func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) { FROM doc_block_tags t JOIN documents d ON t.doc_id = d.id LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ? - WHERE (d.user_id = ? OR s.user_id = ?) + LEFT JOIN ( + SELECT dts2.doc_id + FROM doc_team_shares dts2 + JOIN team_members tm ON tm.team_id = dts2.team_id + WHERE tm.user_id = ? + ) dts ON d.id = dts.doc_id + WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL) AND d.is_archived = 0 GROUP BY t.tag ORDER BY count DESC, t.tag ASC`, - userID, userID, userID, + userID, userID, userID, userID, ) if err != nil { return nil, err @@ -1686,16 +1714,22 @@ func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) { rows, err := s.db.Query( `SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id, COALESCE(u.display_name, '') as owner_name, - COALESCE(s.permission, '') as shared_permission + COALESCE(s.permission, dts.permission, '') as shared_permission FROM doc_block_tags t JOIN documents d ON t.doc_id = d.id LEFT JOIN users u ON d.user_id = u.id LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ? - WHERE (d.user_id = ? OR s.user_id = ?) + LEFT JOIN ( + SELECT dts2.doc_id, dts2.permission + FROM doc_team_shares dts2 + JOIN team_members tm ON tm.team_id = dts2.team_id + WHERE tm.user_id = ? + ) dts ON d.id = dts.doc_id + WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL) AND t.tag = ? AND d.is_archived = 0 ORDER BY d.updated_at DESC`, - userID, userID, userID, cleanTag, + userID, userID, userID, userID, cleanTag, ) if err != nil { return nil, err @@ -1946,7 +1980,10 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) { } func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) { - rows, err := s.db.Query( + shares := make([]domain.DocShare, 0) + + // 1. Direct user shares + uRows, err := s.db.Query( `SELECT s.id, s.doc_id, s.user_id, u.email, u.display_name, s.permission, s.created_by, s.created_at FROM doc_shares s JOIN users u ON s.user_id = u.id @@ -1954,19 +1991,89 @@ func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) { ORDER BY s.created_at ASC`, docID, ) + if err == nil { + for uRows.Next() { + var sh domain.DocShare + if err := uRows.Scan(&sh.ID, &sh.DocID, &sh.UserID, &sh.UserEmail, &sh.DisplayName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil { + sh.TargetType = "user" + sh.TargetID = sh.UserID + sh.TargetName = sh.DisplayName + shares = append(shares, sh) + } + } + _ = uRows.Close() + } + + // 2. Team shares + tRows, err := s.db.Query( + `SELECT s.id, s.doc_id, s.team_id, t.name, s.permission, s.created_by, s.created_at + FROM doc_team_shares s + JOIN teams t ON s.team_id = t.id + WHERE s.doc_id = ? + ORDER BY s.created_at ASC`, + docID, + ) + if err == nil { + for tRows.Next() { + var sh domain.DocShare + var teamID, teamName string + if err := tRows.Scan(&sh.ID, &sh.DocID, &teamID, &teamName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil { + sh.TargetType = "team" + sh.TargetID = teamID + sh.TargetName = teamName + sh.DisplayName = teamName + shares = append(shares, sh) + } + } + _ = tRows.Close() + } + + return shares, nil +} + +func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission string) (*domain.DocShare, error) { + doc, err := s.GetDocumentByID(currentUserID, docID) if err != nil { return nil, err } - defer rows.Close() - - shares := make([]domain.DocShare, 0) - for rows.Next() { - var sh domain.DocShare - if err := rows.Scan(&sh.ID, &sh.DocID, &sh.UserID, &sh.UserEmail, &sh.DisplayName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil { - shares = append(shares, sh) - } + if !doc.IsOwner && doc.Permission != "write" { + return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten") } - return shares, nil + + 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, fmt.Errorf("team nicht gefunden") + } + + if permission != "read" && permission != "write" { + permission = "read" + } + + now := domain.NowISO() + shareID := domain.NewID("share") + _, err = s.db.Exec( + `INSERT INTO doc_team_shares (id, doc_id, team_id, permission, created_at, created_by) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(doc_id, team_id) DO UPDATE SET permission = excluded.permission`, + shareID, docID, cleanTeamID, permission, now, currentUserID, + ) + if err != nil { + return nil, err + } + + return &domain.DocShare{ + ID: shareID, + DocID: docID, + TargetType: "team", + TargetID: cleanTeamID, + TargetName: teamName, + DisplayName: teamName, + Permission: permission, + CreatedBy: currentUserID, + CreatedAt: now, + }, nil } func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permission string) (*domain.DocShare, error) { @@ -1978,13 +2085,19 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten") } + cleanTarget := strings.TrimSpace(targetUserIdentifier) + + // If identifier looks like a team or is prefixed with team_, route to CreateDocTeamShare + if strings.HasPrefix(cleanTarget, "team_") { + return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission) + } + if permission != "read" && permission != "write" { permission = "read" } // Resolve target user by ID, username, or Email var targetUser *domain.User - cleanTarget := strings.TrimSpace(targetUserIdentifier) if strings.HasPrefix(cleanTarget, "usr_") { targetUser, _ = s.GetUserByID(cleanTarget) } @@ -1995,6 +2108,11 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi targetUser, err = s.GetUserByID(cleanTarget) } if err != nil || targetUser == nil { + // Check if it's a team ID that doesn't start with team_ + var tName string + if s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTarget).Scan(&tName) == nil { + return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission) + } return nil, fmt.Errorf("benutzer nicht gefunden") } @@ -2017,6 +2135,9 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi return &domain.DocShare{ ID: shareID, DocID: docID, + TargetType: "user", + TargetID: targetUser.ID, + TargetName: targetUser.DisplayName, UserID: targetUser.ID, UserEmail: targetUser.Email, DisplayName: targetUser.DisplayName, @@ -2026,16 +2147,26 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi }, nil } -func (s *Store) DeleteDocShare(currentUserID, docID, targetUserID string) error { +func (s *Store) DeleteDocShare(currentUserID, docID, targetID string) error { doc, err := s.GetDocumentByID(currentUserID, docID) if err != nil { return err } - if !doc.IsOwner && doc.Permission != "write" && currentUserID != targetUserID { + if !doc.IsOwner && doc.Permission != "write" && currentUserID != targetID { return fmt.Errorf("keine Berechtigung zum Entfernen der Freigabe") } - _, err = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ? AND user_id = ?`, docID, targetUserID) + cleanTarget := strings.TrimSpace(targetID) + // Try deleting team share first + res, err := s.db.Exec(`DELETE FROM doc_team_shares WHERE doc_id = ? AND (id = ? OR team_id = ?)`, docID, cleanTarget, cleanTarget) + if err == nil { + if rows, _ := res.RowsAffected(); rows > 0 { + return nil + } + } + + // Fallback to user share + _, err = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ? AND (id = ? OR user_id = ?)`, docID, cleanTarget, cleanTarget) return err } @@ -2186,7 +2317,12 @@ func (s *Store) AddSpaceMember(currentUserID, spaceID, targetUserIdentifier, rol return nil, errors.New("benutzer nicht gefunden") } - if role != "owner" && role != "editor" && role != "viewer" { + // 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" } @@ -2210,6 +2346,135 @@ func (s *Store) AddSpaceMember(currentUserID, spaceID, targetUserIdentifier, rol }, 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 + } + + // Add team members as space members, but preserve owner role! + members, _ := s.GetTeamMembers(cleanTeamID) + for _, tm := range members { + var curRole string + _ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, tm.UserID).Scan(&curRole) + if curRole == "owner" { + continue + } + _, _ = 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, tm.UserID, role, now, + ) + } + + 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) @@ -2217,12 +2482,17 @@ func (s *Store) RemoveSpaceMember(currentUserID, spaceID, targetUserID string) e return errors.New("keine Berechtigung für diesen Bereich") } - if currentUserID != targetUserID && myRole != "owner" { + 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, targetUserID).Scan(&targetRole) + _ = 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) @@ -2231,7 +2501,7 @@ func (s *Store) RemoveSpaceMember(currentUserID, spaceID, targetUserID string) e } } - _, err = s.db.Exec(`DELETE FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUserID) + _, err = s.db.Exec(`DELETE FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, cleanTarget) return err } @@ -2239,10 +2509,12 @@ 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 - JOIN space_members sm ON sm.space_id = s.id - WHERE sm.user_id = ? + 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, userID, ) if err != nil { return nil, err @@ -2261,6 +2533,7 @@ func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) { 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 @@ -2729,12 +3002,15 @@ func (s *Store) AddTaskListShare(taskListID, targetType, targetID, createdBy str _, err := s.db.Exec( `INSERT INTO task_list_shares (id, task_list_id, target_type, target_id, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(task_list_id, target_type, target_id) DO NOTHING`, + ON CONFLICT(task_list_id, target_type, target_id) DO UPDATE SET created_at = excluded.created_at`, share.ID, share.TaskListID, share.TargetType, share.TargetID, share.CreatedAt, createdBy, ) if err != nil { return nil, err } + _ = s.db.QueryRow(`SELECT id, created_at FROM task_list_shares WHERE task_list_id = ? AND target_type = ? AND target_id = ?`, + taskListID, targetType, targetID).Scan(&share.ID, &share.CreatedAt) + if targetType == "team" { _, _ = s.db.Exec(`UPDATE task_lists SET team_id = ? WHERE id = ? AND (team_id = '' OR team_id IS NULL)`, targetID, taskListID) _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName) @@ -3181,14 +3457,28 @@ func (s *Store) GetTeamsForOrg(orgID string) ([]domain.Team, error) { return teams, nil } +func (s *Store) GetTeamByID(teamID string) (*domain.Team, error) { + var t domain.Team + err := s.db.QueryRow( + `SELECT id, org_id, name, description, created_at FROM teams WHERE id = ?`, + teamID, + ).Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt) + if err != nil { + return nil, err + } + t.Members, _ = s.GetTeamMembers(t.ID) + return &t, nil +} + func (s *Store) GetTeamsForUser(userID string) ([]domain.Team, error) { rows, err := s.db.Query( `SELECT DISTINCT t.id, t.org_id, t.name, t.description, t.created_at FROM teams t - JOIN team_members tm ON tm.team_id = t.id - WHERE tm.user_id = ? + LEFT JOIN team_members tm ON tm.team_id = t.id + LEFT JOIN organizations o ON o.id = t.org_id + WHERE tm.user_id = ? OR o.owner_id = ? ORDER BY t.created_at ASC`, - userID, + userID, userID, ) if err != nil { return []domain.Team{}, err