From 177b69234c932f7d5c62bd8097e00e635371ce83 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 17:41:18 +0100 Subject: [PATCH] feat: unify sharing modal and clean up team share duplication in lists --- frontend/src/components/UnifiedShareModal.tsx | 356 ++++++++++++++++++ .../src/features/lists/SharedListsView.tsx | 323 ++++------------ frontend/src/features/triage/TasksView.tsx | 271 +++---------- .../src/features/workspace/WorkspaceView.tsx | 331 +++------------- frontend/src/lib/types.ts | 12 + internal/storage/sqlite.go | 32 +- 6 files changed, 558 insertions(+), 767 deletions(-) create mode 100644 frontend/src/components/UnifiedShareModal.tsx diff --git a/frontend/src/components/UnifiedShareModal.tsx b/frontend/src/components/UnifiedShareModal.tsx new file mode 100644 index 0000000..fc487ee --- /dev/null +++ b/frontend/src/components/UnifiedShareModal.tsx @@ -0,0 +1,356 @@ +import React, { useState } from 'react'; +import { + Users, + User as UserIcon, + X, + Plus, + Trash2, + Lock, + Share2, + Check, +} from 'lucide-react'; +import { User, Team, UnifiedShare } from '../lib/types'; +import { UserAutocomplete } from '../features/UserAutocomplete'; + +export interface UnifiedShareModalProps { + isOpen: boolean; + onClose: () => void; + title?: string; + resourceName: string; + resourceIcon?: string | React.ReactNode; + isOwner: boolean; + currentUserId?: string; + shares: UnifiedShare[]; + teams: Team[]; + collaborators: User[]; + allowRoleSelection?: boolean; // Default true (editor vs viewer) + onAddShare: (targetType: 'team' | 'user', targetId: string, permission: 'editor' | 'viewer') => Promise; + onRemoveShare: (shareId: string) => Promise; +} + +export const UnifiedShareModal: React.FC = ({ + isOpen, + onClose, + title = 'Freigeben & Berechtigungen', + resourceName, + resourceIcon, + isOwner, + currentUserId, + shares, + teams, + collaborators, + allowRoleSelection = true, + onAddShare, + onRemoveShare, +}) => { + const [shareType, setShareType] = useState<'team' | 'user'>('team'); + const [selectedTeamId, setSelectedTeamId] = useState(''); + const [selectedUser, setSelectedUser] = useState(null); + const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>('editor'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + if (!isOpen) return null; + + const handleAdd = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + const targetId = shareType === 'team' ? selectedTeamId : selectedUser?.id; + if (!targetId) { + setError(shareType === 'team' ? 'Bitte eine Gruppe auswählen' : 'Bitte einen Benutzer auswählen'); + return; + } + + setLoading(true); + try { + await onAddShare(shareType, targetId, selectedPermission); + setSelectedTeamId(''); + setSelectedUser(null); + } catch (err: any) { + setError(err?.message || 'Fehler beim Hinzufügen der Freigabe'); + } finally { + setLoading(false); + } + }; + + const handleRemove = async (shareId: string) => { + if (!window.confirm('Möchtest du diese Freigabe wirklich aufheben?')) return; + setError(null); + setLoading(true); + try { + await onRemoveShare(shareId); + } catch (err: any) { + setError(err?.message || 'Fehler beim Entfernen der Freigabe'); + } finally { + setLoading(false); + } + }; + + const excludedUserIds = [ + ...(currentUserId ? [currentUserId] : []), + ...shares.filter((s) => s.targetType === 'user').map((s) => s.targetId), + ]; + + const availableTeams = teams.filter( + (t) => !shares.some((s) => s.targetType === 'team' && s.targetId === t.id) + ); + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Header */} +
+
+
+ {resourceIcon ? ( + typeof resourceIcon === 'string' ? ( + {resourceIcon} + ) : ( + resourceIcon + ) + ) : ( + + )} +
+
+

{title}

+

+ Objekt: {resourceName} +

+
+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Add Share Section (Only if owner / manager) */} + {isOwner ? ( +
+
+ Freigabe hinzufügen: +
+ + +
+
+ + {shareType === 'team' ? ( +
+ + {allowRoleSelection && ( + + )} +
+ ) : ( +
+
+ +
+ {allowRoleSelection && ( + + )} +
+ )} + +
+ +
+
+ ) : ( +
+ + Nur der Eigentümer kann neue Freigaben einrichten. +
+ )} + + {/* Active Shares List */} +
+
+ Aktive Freigaben + + {shares.length} {shares.length === 1 ? 'Freigabe' : 'Freigaben'} + +
+ +
+ {shares.length === 0 ? ( +
+ + Dieses Element ist aktuell privat (nur für dich sichtbar). +
+ ) : ( + shares.map((sh) => { + const isTeam = sh.targetType === 'team'; + const roleLabel = + sh.permission === 'viewer' || sh.role === 'viewer' ? 'Leser' : 'Bearbeiter'; + + return ( +
+
+
+ {isTeam ? : } +
+
+
+ {sh.targetName || (isTeam ? 'Gruppe' : sh.targetEmail || 'Benutzer')} + + {isTeam ? 'Gruppe' : 'Benutzer'} + +
+ {sh.targetEmail && sh.targetEmail !== sh.targetName && ( +
{sh.targetEmail}
+ )} +
+
+ +
+ + {roleLabel} + + {isOwner && ( + + )} +
+
+ ); + }) + )} +
+
+ + {/* Footer */} +
+ +
+
+
+ ); +}; diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 903f199..e992ece 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -16,6 +16,7 @@ import { import { ListItem, SharedList, Space, Team, User, WSMessage } from '../../lib/types'; import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api'; import { UserAutocomplete } from '../UserAutocomplete'; +import { UnifiedShareModal } from '../../components/UnifiedShareModal'; interface SharedListsViewProps { onRefreshGlobal: () => void; @@ -738,255 +739,81 @@ export const SharedListsView: React.FC = ({ onRefreshGloba ) : null} - {/* Space & Shopping List Sharing Modal */} + {/* Unified Space & Shopping List Sharing Modal */} {showShareModal && activeSpace && ( -
-
- {/* Modal Header */} -
-
- -

- Einkaufsliste teilen: {activeSpace.name} -

-
- -
- - {shareError && ( -
- {shareError} -
- )} - - {/* Sharing Form (Team / Gruppe vs. Einzelner Benutzer) */} -
-
- Freigabe hinzufügen: -
- - -
-
- - {addShareType === 'team' ? ( -
- - -
- ) : ( -
-
- m.userId), - ...(activeSpace.shares || []) - .filter((s) => s.targetType === 'user') - .map((s) => s.targetId), - ].filter(Boolean)} - disabled={invitingLoading} - placeholder="Benutzer suchen (Name oder @Benutzername)…" - /> -
- -
- )} - -
- -
-
- - {/* Current Space Members & Team Shares List */} -
-
- 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; - const canRemove = - (activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin') && - !isOwner; - - return ( -
-
-
- {(m.displayName || m.email || '?')[0].toUpperCase()} -
-
-
- {m.displayName || m.email} {isMe && (Du)} -
-
{m.email}
-
-
- -
- - {isOwner ? 'Eigentümer' : m.role === 'editor' ? 'Bearbeiter' : 'Leser'} - - {canRemove && ( - - )} -
-
- ); - })} -
-
-
-
+ setShowShareModal(false)} + title={`Einkaufsliste teilen: ${activeSpace.name}`} + resourceName={activeSpace.name} + resourceIcon={activeSpace.icon || '🛒'} + isOwner={activeSpace.ownerId === currentUser?.id || 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, + })), + ]} + 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 }); + } + 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 }); + } + onRefreshGlobal(); + }} + /> )} ); diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 4932ba1..f1d05b3 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -28,6 +28,7 @@ import { } from 'lucide-react'; import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; +import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { TaskDetailModal } from './TaskDetailModal'; import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal'; import { UserAutocomplete } from '../UserAutocomplete'; @@ -1233,222 +1234,62 @@ export const TasksView: React.FC = ({ )} - {/* SHARE LIST MODAL */} + {/* UNIFIED SHARE LIST MODAL */} {showShareListModal && sharingTaskList && ( -
-
-
-
- {sharingTaskList.icon || '📋'} -
-

- Liste teilen: {sharingTaskList.title} -

-

- {sharingTaskList.shares && sharingTaskList.shares.length > 0 - ? `${sharingTaskList.shares.length} Freigabe(n) aktiv` - : 'Aktuell privat (nur für dich)'} -

-
-
- -
- -
- {/* Active Shares */} -
- - {sharingTaskList.shares && sharingTaskList.shares.length > 0 ? ( -
- {sharingTaskList.shares.map((sh) => ( -
-
-
- {sh.targetType === 'team' ? ( - - ) : ( - - )} -
-
-

- {sh.targetName || (sh.targetType === 'team' ? 'Team' : sh.email || 'Benutzer')} -

- {sh.email && sh.email !== sh.targetName && ( -

{sh.email}

- )} -
-
-
- - {sh.targetType === 'team' ? 'Team' : 'Benutzer'} - - {sharingTaskList.isOwner !== false && ( - - )} -
-
- ))} -
- ) : ( -
- - Diese Liste ist privat. Füge unten ein Team oder einen Benutzer hinzu. -
- )} -
- - {/* Add New Share */} - {sharingTaskList.isOwner !== false && ( -
- -
- - -
- -
- {addShareType === 'team' ? ( - - ) : ( -
- s.targetType === 'user') - .map((s) => s.targetId), - ].filter(Boolean)} - disabled={shareLoading} - placeholder="Benutzer suchen (Name oder @Benutzername)…" - /> -
- )} - -
-
- )} - - {/* Persons with access summary */} - {sharingTaskList.members && sharingTaskList.members.length > 0 && ( -
-

- Personen mit Zugriff ({sharingTaskList.members.length}): -

-
- {sharingTaskList.members.map((m) => ( -
-
-
- {getInitials(m.displayName || m.email)} -
- - {m.displayName || m.email} - -
- - {m.role === 'owner' ? 'Eigentümer' : m.role === 'editor' ? 'Bearbeiter' : 'Mitglied'} - -
- ))} -
-
- )} -
- -
- -
-
-
+ { + setShowShareListModal(false); + setSharingTaskList(null); + }} + title={`Aufgabenliste teilen: ${sharingTaskList.title}`} + resourceName={sharingTaskList.title} + resourceIcon={sharingTaskList.icon || '📋'} + isOwner={sharingTaskList.isOwner !== false} + currentUserId={currentUser?.id} + shares={(sharingTaskList.shares || []).map((sh) => ({ + id: sh.id, + targetType: sh.targetType, + targetId: sh.targetId, + targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'), + targetEmail: sh.email, + permission: 'editor' as const, + role: 'editor', + createdAt: sh.createdAt, + }))} + teams={safeTeams} + collaborators={safeCollaborators} + allowRoleSelection={false} + onAddShare={async (targetType, targetId) => { + await api.addTaskListShare(sharingTaskList.id, { + targetType, + targetId, + }); + 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, shares: freshShares }); + } + await loadData(); + }} + onRemoveShare={async (shareId) => { + await api.removeTaskListShare(sharingTaskList.id, shareId); + 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, shares: freshShares }); + } + await loadData(); + }} + /> )} {/* MANAGE COLUMNS MODAL */} diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 058a091..236ee51 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -54,6 +54,7 @@ import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, TagDetails, import { api, getToken, triggerHaptic } from '../../lib/api'; import { CodeBlockItem } from './CodeBlockItem'; import { UserAutocomplete } from '../UserAutocomplete'; +import { UnifiedShareModal } from '../../components/UnifiedShareModal'; interface WorkspaceViewProps { currentUser?: User | null; @@ -4328,291 +4329,53 @@ export const WorkspaceView: React.FC = ({ )} - {/* ===================================================================== */} - {/* DOCUMENT SHARING MODAL */} - {/* ===================================================================== */} + {/* UNIFIED DOCUMENT SHARING MODAL */} {showShareModal && selectedDoc && ( -
-
- {/* Modal Header */} -
-
- -

Notiz teilen & Berechtigungen

-
- -
- -
- Dokument: {selectedDoc.title || 'Unbenannte Notiz'} -
- - {shareError && ( -
- {shareError} -
- )} - - {/* If current user is owner: can add shares and manage permissions */} - {selectedDoc.isOwner !== false ? ( -
-
-
- Freigabe hinzufügen: -
- - -
-
- - {addShareType === 'team' ? ( -
- - -
- ) : ( -
-
- s.targetId || s.userId).filter(Boolean), - ]} - disabled={sharingLoading} - placeholder="Benutzer suchen (Name oder @Benutzername)…" - /> -
- -
- )} - -
- -
-
- - {/* List of current shares */} -
-
Aktuelle Freigaben ({docShares.length})
- {docShares.length === 0 ? ( -
- Diese Notiz ist bisher noch mit niemandem geteilt. -
- ) : ( -
- {docShares.map((share) => { - const isTeam = share.targetType === 'team' || Boolean((share as any).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}
- )} -
-
- -
- {!isTeam && ( - - )} - {isTeam && ( - - {share.permission === 'write' ? 'Schreiben' : 'Nur Lesen'} - - )} - - -
-
- ); - })} -
- )} -
-
- ) : ( - /* If current user is a collaborator */ -
- {/* Document & Owner Card */} -
-
- Eigentümer der Notiz: - - - {(selectedDoc.ownerName || 'U').slice(0, 1).toUpperCase()} - - {selectedDoc.ownerName || 'Unbekannt'} - -
-
- - {/* Permission Card */} - {selectedDoc.permission === 'write' ? ( -
-
- -
-
-
Schreibzugriff aktiv
-
- Du hast Bearbeitungsrechte für diese Notiz und kannst Inhalte in Echtzeit anpassen. -
-
-
- ) : ( -
-
- -
-
-
Nur Lesezugriff
-
- Du kannst diese Notiz ansehen und durchsuchen, aber keine Änderungen an den Inhalten vornehmen. -
-
-
- )} - - {/* Action: Leave Share */} -
- -
-
- )} - -
- -
-
-
+ setShowShareModal(false)} + title={`Notiz teilen: ${selectedDoc.title || 'Unbenannte Notiz'}`} + resourceName={selectedDoc.title || 'Unbenannte Notiz'} + resourceIcon={selectedDoc.icon || '📄'} + isOwner={selectedDoc.isOwner !== false} + currentUserId={currentUser?.id} + shares={(docShares || []).map((sh) => ({ + id: sh.id, + targetType: sh.targetType || (sh.teamId ? 'team' : 'user'), + targetId: sh.targetId || sh.teamId || sh.userId || '', + targetName: sh.targetName || sh.displayName || (sh.targetType === 'team' || sh.teamId ? 'Gruppe' : sh.userEmail || 'Benutzer'), + targetEmail: sh.userEmail, + permission: sh.permission === 'read' ? ('viewer' as const) : ('editor' as const), + role: sh.permission === 'read' ? 'viewer' : 'editor', + createdAt: sh.createdAt, + }))} + teams={shareTeams} + collaborators={collaborators} + allowRoleSelection={true} + onAddShare={async (targetType, targetId, permission) => { + const perm = permission === 'viewer' ? 'read' : 'write'; + if (targetType === 'team') { + await api.createDocShare(selectedDoc.id, { + teamId: targetId, + permission: perm, + }); + } else { + await api.createDocShare(selectedDoc.id, { + userId: targetId, + permission: perm, + }); + } + const shares = await api.getDocShares(selectedDoc.id); + setDocShares(shares); + await loadDocs(selectedDoc.id); + }} + onRemoveShare={async (shareId) => { + await api.deleteDocShare(selectedDoc.id, shareId); + const shares = await api.getDocShares(selectedDoc.id); + setDocShares(shares); + await loadDocs(selectedDoc.id); + }} + /> )} {/* LINK INSERTION MODAL (/link, /notiz, /web) */} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 577829f..07c49f7 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -243,6 +243,18 @@ export interface SpaceMember { joinedAt: string; } +export interface UnifiedShare { + id: string; + targetType: 'team' | 'user'; + targetId: string; + targetName?: string; + targetEmail?: string; + permission?: 'editor' | 'viewer'; + role?: string; + createdBy?: string; + createdAt: string; +} + export interface SpaceShare { id: string; spaceId: string; diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 0425e44..674ece8 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -2374,21 +2374,6 @@ func (s *Store) CreateSpaceTeamShare(currentUserID, spaceID, teamID, role string 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, @@ -2596,12 +2581,13 @@ func (s *Store) GetListsForSpace(spaceID string) ([]domain.SharedList, error) { func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) { rows, err := s.db.Query( - `SELECT l.id, l.space_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at + `SELECT DISTINCT l.id, l.space_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at FROM lists l - JOIN space_members sm ON sm.space_id = l.space_id - WHERE sm.user_id = ? + 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 ORDER BY l.updated_at DESC`, - userID, + userID, userID, userID, ) if err != nil { return nil, err @@ -2840,7 +2826,13 @@ func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error) _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) AND is_completed = 1`, userID).Scan(&st.TasksDoneCount) _ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE user_id = ? AND is_archived = 0`, userID).Scan(&st.DocsCount) _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID).Scan(&st.DiscardedCount) - _ = s.db.QueryRow(`SELECT COUNT(*) FROM lists l JOIN space_members sm ON sm.space_id = l.space_id WHERE sm.user_id = ?`, userID).Scan(&st.ListsCount) + _ = 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`, + userID, userID, userID).Scan(&st.ListsCount) // Count open (uncompleted) To-Dos across accessible workspace documents (owned or shared) docRows, err := s.db.Query(`