Dev #4

Merged
sandrodach merged 12 commits from dev into main 2026-10-25 21:09:45 +01:00
9 changed files with 588 additions and 922 deletions
Showing only changes of commit 0d6c603e9c - Show all commits

No files matched your search

+88 -209
View File
@@ -9,13 +9,10 @@ import {
Radio, Radio,
Sparkles, Sparkles,
Share, Share,
Share2,
User as UserIcon,
X, X,
} from 'lucide-react'; } 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 { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
import { UserAutocomplete } from '../UserAutocomplete';
import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { UnifiedShareModal } from '../../components/UnifiedShareModal';
interface SharedListsViewProps { interface SharedListsViewProps {
@@ -69,8 +66,6 @@ export function getItemDisplayContent(item: ListItem): string {
} }
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser }) => { export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser }) => {
const [spaces, setSpaces] = useState<Space[]>([]);
const [activeSpace, setActiveSpace] = useState<Space | null>(null);
const [lists, setLists] = useState<SharedList[]>([]); const [lists, setLists] = useState<SharedList[]>([]);
const [activeListId, setActiveListId] = useState<string>(''); const [activeListId, setActiveListId] = useState<string>('');
@@ -85,61 +80,47 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [showShareModal, setShowShareModal] = useState(false); const [showShareModal, setShowShareModal] = useState(false);
const [collaborators, setCollaborators] = useState<User[]>([]); const [collaborators, setCollaborators] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]); const [teams, setTeams] = useState<Team[]>([]);
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
const [selectedTeamId, setSelectedTeamId] = useState('');
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
const [inviteTarget, setInviteTarget] = useState('');
const [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor');
const [invitingLoading, setInvitingLoading] = useState(false);
const [shareError, setShareError] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const loadSpaces = async () => { const loadLists = async () => {
let sp = await api.getSpaces();
if (sp.length === 0) {
try { try {
const created = await api.createSpace('Meine Listen', '🛒', 'Persönliche und geteilte Einkaufslisten'); let all = await api.getAllLists();
sp = [created]; if (all.length === 0) {
try {
const created = await api.createList('Wocheneinkauf', '🛒', 'shopping');
all = [created];
} catch { } catch {
// fallback // fallback
} }
} }
setSpaces(sp); setLists(all);
if (sp.length > 0) { if (all.length > 0 && (!activeListId || !all.some((l) => l.id === activeListId))) {
if (!activeSpace) { setActiveListId(all[0].id);
setActiveSpace(sp[0]);
} else {
const found = sp.find((s) => s.id === activeSpace.id);
if (found) setActiveSpace(found);
} }
} } catch {
}; // ignore
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);
} }
}; };
useEffect(() => { useEffect(() => {
loadSpaces(); loadLists();
}, []); }, []);
const activeList = useMemo(() => {
return lists.find((l) => l.id === activeListId) || lists[0] || null;
}, [lists, activeListId]);
useEffect(() => { useEffect(() => {
if (!activeSpace) return; if (!activeList?.id) return;
loadListsForSpace(activeSpace.id);
// Connect Realtime WebSocket for this Space
if (wsRef.current) { if (wsRef.current) {
wsRef.current.close(); wsRef.current.close();
} }
let ws: WebSocket | null = null; let ws: WebSocket | null = null;
try { try {
ws = new WebSocket(buildWebSocketURL(activeSpace.id)); ws = new WebSocket(buildWebSocketURL(activeList.id));
wsRef.current = ws; wsRef.current = ws;
ws.onopen = () => { ws.onopen = () => {
@@ -153,41 +134,49 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
ws.onmessage = (evt) => { ws.onmessage = (evt) => {
try { try {
const msg: WSMessage = JSON.parse(evt.data); 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; const newItem: ListItem = msg.payload;
setLists((prev) => setLists((prev) =>
prev.map((l) => { 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; if (l.items.some((i) => i.id === newItem.id)) return l;
return { ...l, items: [newItem, ...l.items] }; 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; const updatedItem: ListItem = msg.payload;
setLists((prev) => setLists((prev) =>
prev.map((l) => { prev.map((l) => {
if (l.id !== msg.list_id) return l; if (l.id !== msgListId) return l;
return { return {
...l, ...l,
items: l.items.map((i) => (i.id === updatedItem.id ? updatedItem : i)), 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; const deletedId = msg.payload?.itemId;
setLists((prev) => setLists((prev) =>
prev.map((l) => { 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) }; return { ...l, items: l.items.filter((i) => i.id !== deletedId) };
}) })
); );
} else if (msg.event === 'LIST_CLEARED' && msg.list_id) { } else if (msg.event === 'LIST_CLEARED' && msgListId) {
const updatedList: SharedList = msg.payload; setLists((prev) =>
setLists((prev) => prev.map((l) => (l.id === msg.list_id ? updatedList : l))); prev.map((l) => {
} else if (msg.event === 'LIST_CREATED' || msg.event === 'LIST_DELETED') { if (l.id !== msgListId) return l;
loadListsForSpace(activeSpace.id); return { ...l, items: l.items.filter((i) => !i.isCompleted) };
} else if (msg.event === 'MEMBER_ADDED' || msg.event === 'MEMBER_REMOVED') { })
loadSpaces(); );
} else if (
msg.event === 'LIST_CREATED' ||
msg.event === 'LIST_DELETED' ||
msg.event === 'LIST_SHARED' ||
msg.event === 'LIST_UNSHARED'
) {
loadLists();
} }
} catch { } catch {
// ignore malformed frames // ignore malformed frames
@@ -200,27 +189,25 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
return () => { return () => {
if (ws) ws.close(); if (ws) ws.close();
}; };
}, [activeSpace?.id]); }, [activeList?.id]);
useEffect(() => { useEffect(() => {
const handleGlobalWs = (e: any) => { const handleGlobalWs = (e: any) => {
const msg = e.detail; const msg = e.detail;
if (!msg) return; if (!msg) return;
if ( if (
msg.event === 'SPACE_JOINED' || msg.event === 'LIST_SHARED' ||
msg.event === 'SPACE_LEFT' || msg.event === 'LIST_UNSHARED' ||
msg.event === 'MEMBER_ADDED' || msg.event === 'LIST_CREATED' ||
msg.event === 'MEMBER_REMOVED' msg.event === 'LIST_DELETED'
) { ) {
loadSpaces(); loadLists();
} }
}; };
window.addEventListener('mindsift:ws', handleGlobalWs); window.addEventListener('mindsift:ws', handleGlobalWs);
return () => window.removeEventListener('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) => { const handleAddItem = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!activeList || !newItemText.trim()) return; if (!activeList || !newItemText.trim()) return;
@@ -275,16 +262,20 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
if (!activeList) return; if (!activeList) return;
triggerHaptic(20); triggerHaptic(20);
await api.clearCompletedItems(activeList.id); 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) => { const handleCreateList = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!activeSpace || !newListTitle.trim()) return; if (!newListTitle.trim()) return;
const created = await api.createList(activeSpace.id, newListTitle.trim(), newListIcon, 'shopping'); const created = await api.createList(newListTitle.trim(), newListIcon, 'shopping');
setNewListTitle(''); setNewListTitle('');
setShowNewListModal(false); setShowNewListModal(false);
await loadListsForSpace(activeSpace.id); await loadLists();
setActiveListId(created.id); setActiveListId(created.id);
onRefreshGlobal(); onRefreshGlobal();
}; };
@@ -293,9 +284,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
if (!window.confirm('Diese Liste wirklich löschen?')) return; if (!window.confirm('Diese Liste wirklich löschen?')) return;
try { try {
await api.deleteList(listId); await api.deleteList(listId);
if (activeSpace) { await loadLists();
await loadListsForSpace(activeSpace.id);
}
onRefreshGlobal(); onRefreshGlobal();
} catch (err: any) { } catch (err: any) {
alert(err.message || 'Liste konnte nicht gelöscht werden'); alert(err.message || 'Liste konnte nicht gelöscht werden');
@@ -303,96 +292,22 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
}; };
const openShareModal = async () => { const openShareModal = async () => {
if (!activeSpace) return; if (!activeList) return;
setShowShareModal(true); setShowShareModal(true);
setShareError(null);
setSelectedTeamId('');
setSelectedShareUser(null);
try { try {
const [usersList, teamsList, sharesList] = await Promise.all([ const [usersList, teamsList, sharesList] = await Promise.all([
api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []), api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []),
api.getUserTeams().catch(() => []), api.getUserTeams().catch(() => []),
api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]), api.getListShares(activeList.id).catch(() => []),
]); ]);
setCollaborators(usersList); setCollaborators(usersList);
setTeams(teamsList); setTeams(teamsList);
setActiveSpace((prev) => (prev ? { ...prev, shares: sharesList } : prev)); setLists((prev) => prev.map((l) => (l.id === activeList.id ? { ...l, shares: sharesList } : l)));
} catch { } catch {
// ignore // 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 openItems = activeList?.items.filter((i) => !i.isCompleted) || [];
const completedItems = activeList?.items.filter((i) => i.isCompleted) || []; const completedItems = activeList?.items.filter((i) => i.isCompleted) || [];
@@ -486,16 +401,11 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
{/* Right Header Section: Geteilt Badge, Icon-Only Share Button & Live Sync */} {/* Right Header Section: Geteilt Badge, Icon-Only Share Button & Live Sync */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{activeSpace && (() => { {activeList && (() => {
const isOwner = !activeSpace.ownerId || activeSpace.ownerId === currentUser?.id; const isOwner = activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin');
const sharesCount = const sharesCount = activeList.shares?.length || 0;
(activeSpace.shares?.length || 0) + const isShared = sharesCount > 0;
(activeSpace.members?.filter((m) => m.userId !== activeSpace.ownerId).length || 0); const ownerDisplayName = activeList.ownerName || 'Eigentümer';
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';
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -508,7 +418,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten" title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
> >
<Users className="w-2.5 h-2.5" /> <Users className="w-2.5 h-2.5" />
<span>Geteilt ({sharesCount > 0 ? sharesCount : activeSpace.members?.length || 1})</span> <span>Geteilt ({sharesCount})</span>
</button> </button>
) : ( ) : (
<button <button
@@ -527,7 +437,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
type="button" type="button"
onClick={openShareModal} onClick={openShareModal}
className="btn-icon" className="btn-icon"
title="Einkaufsliste teilen und Berechtigungen" title="Liste teilen und Berechtigungen"
> >
<Share className="w-3.5 h-3.5" /> <Share className="w-3.5 h-3.5" />
</button> </button>
@@ -739,78 +649,47 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div> </div>
) : null} ) : null}
{/* Unified Space & Shopping List Sharing Modal */} {/* Unified Shopping List Sharing Modal */}
{showShareModal && activeSpace && ( {showShareModal && activeList && (
<UnifiedShareModal <UnifiedShareModal
isOpen={showShareModal} isOpen={showShareModal}
onClose={() => setShowShareModal(false)} onClose={() => setShowShareModal(false)}
title={`Einkaufsliste teilen: ${activeSpace.name}`} title={`Liste teilen: ${activeList.title}`}
resourceName={activeSpace.name} resourceName={activeList.title}
resourceIcon={activeSpace.icon || '🛒'} resourceIcon={activeList.icon || '🛒'}
isOwner={activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin'} isOwner={activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin')}
currentUserId={currentUser?.id} currentUserId={currentUser?.id}
shares={[ shares={(activeList.shares || []).map((s) => ({
...(activeSpace.shares || [])
.filter((s) => s.targetType === 'team')
.map((s) => ({
id: s.id, id: s.id,
targetType: 'team' as const, targetType: s.targetType,
targetId: s.targetId, targetId: s.targetId,
targetName: s.targetName || 'Gruppe', targetName: s.targetName || (s.targetType === 'team' ? 'Gruppe' : 'Benutzer'),
targetEmail: s.targetEmail,
permission: s.role === 'viewer' ? ('viewer' as const) : ('editor' as const), permission: s.role === 'viewer' ? ('viewer' as const) : ('editor' as const),
role: s.role, role: s.role,
createdAt: s.createdAt, 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} teams={teams}
collaborators={collaborators} collaborators={collaborators}
allowRoleSelection={true} allowRoleSelection={true}
onAddShare={async (targetType, targetId, permission) => { onAddShare={async (targetType, targetId, permission) => {
if (targetType === 'team') { await api.addListShare(activeList.id, {
await api.addSpaceMember(activeSpace.id, { targetType,
teamId: targetId, targetId,
role: permission, role: permission,
}); });
} else { const freshShares = await api.getListShares(activeList.id);
await api.addSpaceMember(activeSpace.id, { setLists((prev) =>
userId: targetId, prev.map((l) => (l.id === activeList.id ? { ...l, shares: freshShares } : l))
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(); onRefreshGlobal();
}} }}
onRemoveShare={async (shareId) => { onRemoveShare={async (shareId) => {
await api.removeSpaceMember(activeSpace.id, shareId); await api.removeListShare(activeList.id, shareId);
const [sp, freshShares] = await Promise.all([ const freshShares = await api.getListShares(activeList.id);
api.getSpaces(), setLists((prev) =>
api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]), prev.map((l) => (l.id === activeList.id ? { ...l, shares: freshShares } : l))
]); );
setSpaces(sp);
const updated = sp.find((s) => s.id === activeSpace.id);
if (updated) {
setActiveSpace({ ...updated, shares: freshShares });
}
onRefreshGlobal(); onRefreshGlobal();
}} }}
/> />
+42 -6
View File
@@ -8,6 +8,7 @@ import {
DocLockInfo, DocLockInfo,
KanbanColumn, KanbanColumn,
ListItem, ListItem,
ListShare,
Organization, Organization,
PriorityLevel, PriorityLevel,
SharedList, SharedList,
@@ -639,17 +640,36 @@ export const api = {
return ensureArray(res).map((l) => ({ return ensureArray(res).map((l) => ({
...l, ...l,
items: ensureArray(l.items), items: ensureArray(l.items),
shares: ensureArray(l.shares),
})); }));
}, },
async createList(spaceId: string, title: string, icon: string, listType: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> { async getListById(listId: string): Promise<SharedList> {
const l = await request<SharedList>(`/api/v1/spaces/${spaceId}/lists`, { const l = await request<SharedList>(`/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<SharedList> {
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<SharedList>('/api/v1/lists', {
method: 'POST', method: 'POST',
body: JSON.stringify({ title, icon, listType }), body: JSON.stringify({ title, icon, listType: type }),
}); });
return { return {
...l, ...l,
items: ensureArray(l.items), items: ensureArray(l.items),
shares: ensureArray(l.shares),
}; };
}, },
@@ -657,6 +677,22 @@ export const api = {
await request(`/api/v1/lists/${listId}`, { method: 'DELETE' }); await request(`/api/v1/lists/${listId}`, { method: 'DELETE' });
}, },
async getListShares(listId: string): Promise<ListShare[]> {
const res = await request<ListShare[]>(`/api/v1/lists/${listId}/shares`);
return ensureArray(res);
},
async addListShare(listId: string, target: { targetType: 'team' | 'user'; targetId: string; role?: string }): Promise<ListShare> {
return request<ListShare>(`/api/v1/lists/${listId}/shares`, {
method: 'POST',
body: JSON.stringify(target),
});
},
async removeListShare(listId: string, shareId: string): Promise<void> {
await request(`/api/v1/lists/${listId}/shares/${shareId}`, { method: 'DELETE' });
},
async createListItem(listId: string, content: string, category = '', quantity = ''): Promise<ListItem> { async createListItem(listId: string, content: string, category = '', quantity = ''): Promise<ListItem> {
return request<ListItem>(`/api/v1/lists/${listId}/items`, { return request<ListItem>(`/api/v1/lists/${listId}/items`, {
method: 'POST', method: 'POST',
@@ -905,11 +941,11 @@ let wsReconnectTimer: any = null;
let wsListeners: ((msg: WSMessage) => void)[] = []; let wsListeners: ((msg: WSMessage) => void)[] = [];
let manualDisconnect = false; let manualDisconnect = false;
export function buildWebSocketURL(spaceId?: string): string { export function buildWebSocketURL(listId?: string): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = getToken() || ''; const token = getToken() || '';
if (spaceId) { if (listId) {
return `${protocol}//${window.location.host}/api/v1/ws/spaces/${encodeURIComponent(spaceId)}?token=${encodeURIComponent(token)}`; 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)}`; return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`;
} }
+19 -1
View File
@@ -295,13 +295,30 @@ export interface ListItem {
completedAt?: string; 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 { export interface SharedList {
id: string; id: string;
spaceId: string; userId?: string;
ownerName?: string;
isOwner?: boolean;
role?: string;
spaceId?: string;
title: string; title: string;
icon: string; icon: string;
listType: 'shopping' | 'checklist' | 'vacation'; listType: 'shopping' | 'checklist' | 'vacation';
items: ListItem[]; items: ListItem[];
shares?: ListShare[];
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@@ -331,6 +348,7 @@ export interface WSMessage<T = any> {
event: string; event: string;
space_id?: string; space_id?: string;
list_id?: string; list_id?: string;
listId?: string;
payload: T; payload: T;
timestamp: string; timestamp: string;
} }
+119 -182
View File
@@ -63,6 +63,7 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest) r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest)
r.Get("/api/v1/ws", s.handleWebSocket) r.Get("/api/v1/ws", s.handleWebSocket)
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket) r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
// Authenticated API Routes // Authenticated API Routes
r.Group(func(r chi.Router) { 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.Post("/api/v1/documents/{id}/lock/heartbeat", s.handleHeartbeatDocLock)
r.Delete("/api/v1/documents/{id}/lock", s.handleUnlockDoc) r.Delete("/api/v1/documents/{id}/lock", s.handleUnlockDoc)
// Shared Spaces & Realtime Lists // Shared Lists & Realtime Collaboration
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)
r.Get("/api/v1/lists", s.handleGetAllLists) 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.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.Post("/api/v1/lists/{id}/items", s.handleCreateListItem)
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem) r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem) 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 { if converted, convErr := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", target, targetListID, dueDate, taskListID); convErr == nil && converted != nil {
thought = converted thought = converted
if target == "list_item" && targetListID != "" { if target == "list_item" && targetListID != "" {
if lst, lErr := s.store.GetListByID(targetListID); lErr == nil && lst != nil { if lst, lErr := s.store.GetListByID(userID, targetListID); lErr == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst) 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 converted to a list item, broadcast via WebSocket to the space
if convertTo == "list_item" && targetListID != "" { if convertTo == "list_item" && targetListID != "" {
if lst, err := s.store.GetListByID(targetListID); err == nil && lst != nil { if lst, err := s.store.GetListByID(userID, targetListID); err == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst) 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}) writeJSON(w, http.StatusOK, map[string]bool{"unlocked": released})
} }
// --- SPACES & REALTIME LISTS --- // --- REALTIME LISTS & SHARING ---
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)
}
func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
@@ -1229,8 +1082,19 @@ func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, lists) 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) { func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
spaceID := chi.URLParam(r, "id") userID := auth.GetUserID(r.Context())
var req struct { var req struct {
Title string `json:"title"` Title string `json:"title"`
Icon string `json:"icon"` Icon string `json:"icon"`
@@ -1241,28 +1105,96 @@ func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
s.wsHub.Broadcast(spaceID, lst.ID, "LIST_CREATED", lst) s.wsHub.BroadcastListEvent(lst.ID, "LIST_CREATED", lst)
writeJSON(w, http.StatusCreated, lst) writeJSON(w, http.StatusCreated, lst)
} }
func (s *Server) handleDeleteList(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id") listID := chi.URLParam(r, "id")
lst, _ := s.store.GetListByID(listID) if err := s.store.DeleteList(userID, listID); err != nil {
if err := s.store.DeleteList(listID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
if lst != nil { s.wsHub.BroadcastListEvent(listID, "LIST_DELETED", map[string]string{"listId": listID})
s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_DELETED", map[string]string{"listId": listID})
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) 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) { func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id") listID := chi.URLParam(r, "id")
var req struct { var req struct {
@@ -1281,9 +1213,7 @@ func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
return return
} }
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_CREATED", item)
}
writeJSON(w, http.StatusCreated, 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) user, _ := s.store.GetUserByID(userID)
userName := "Familie" userName := "Mitglied"
if user != nil { if user != nil {
userName = user.DisplayName userName = user.DisplayName
} }
@@ -1312,9 +1242,7 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
return return
} }
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_TOGGLED", item)
}
writeJSON(w, http.StatusOK, item) writeJSON(w, http.StatusOK, item)
} }
@@ -1328,12 +1256,10 @@ func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
return return
} }
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { s.wsHub.BroadcastListEvent(listID, "ITEM_DELETED", map[string]string{
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_DELETED", map[string]string{
"itemId": itemID, "itemId": itemID,
"listId": listID, "listId": listID,
}) })
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
} }
@@ -1345,14 +1271,25 @@ func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Reques
return return
} }
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { s.wsHub.BroadcastListEvent(listID, "LIST_CLEARED", map[string]interface{}{
s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_CLEARED", lst) "listId": listID,
} "removed": removed,
})
writeJSON(w, http.StatusOK, map[string]int64{"removed": removed}) writeJSON(w, http.StatusOK, map[string]int64{"removed": removed})
} }
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { 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") token := r.URL.Query().Get("token")
if token == "" { if token == "" {
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") { 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 userID = claims.UserID
} }
} }
s.wsHub.ServeWS(w, r, spaceID, userID) s.wsHub.ServeWS(w, r, roomID, userID)
} }
// --- API KEYS & WEBHOOK INGESTION --- // --- API KEYS & WEBHOOK INGESTION ---
+39 -41
View File
@@ -117,7 +117,6 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
"/api/v1/tasks", "/api/v1/tasks",
"/api/v1/organizations", "/api/v1/organizations",
"/api/v1/teams", "/api/v1/teams",
"/api/v1/spaces",
"/api/v1/lists", "/api/v1/lists",
} }
for _, ep := range arrayEndpoints { for _, ep := range arrayEndpoints {
@@ -190,7 +189,6 @@ func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
"/api/v1/auth/me", "/api/v1/auth/me",
"/api/v1/triage/deck", "/api/v1/triage/deck",
"/api/v1/lists", "/api/v1/lists",
"/api/v1/spaces",
"/api/v1/tasks", "/api/v1/tasks",
"/api/v1/documents", "/api/v1/documents",
} }
@@ -557,104 +555,104 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
t.Fatalf("failed to generate token: %v", err) t.Fatalf("failed to generate token: %v", err)
} }
// 1. Admin creates a space // 1. Admin creates a list
createSpaceBody := []byte(`{"name":"Einkauf WG","icon":"🛒","description":"Gemeinsame Einkaufsliste"}`) createListBody := []byte(`{"title":"Einkauf WG","icon":"🛒","listType":"shopping"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/spaces", bytes.NewReader(createSpaceBody)) req := httptest.NewRequest(http.MethodPost, "/api/v1/lists", bytes.NewReader(createListBody))
req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated { 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 var createdList domain.SharedList
_ = json.Unmarshal(rec.Body.Bytes(), &createdSpace) _ = json.Unmarshal(rec.Body.Bytes(), &createdList)
if createdSpace.ID == "" { if createdList.ID == "" {
t.Fatalf("expected created space with ID, got %+v", createdSpace) t.Fatalf("expected created list with ID, got %+v", createdList)
} }
// 2. Admin invites normalUser by username "user" // 2. Admin shares list with normalUser by username "user"
addMemberBody := []byte(`{"userId":"user","role":"editor"}`) addShareBody := []byte(`{"targetType":"user","targetId":"user","role":"editor"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addMemberBody)) req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addShareBody))
req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated { 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 var newShare domain.ListShare
_ = json.Unmarshal(rec.Body.Bytes(), &newMember) _ = json.Unmarshal(rec.Body.Bytes(), &newShare)
if newMember.UserID != normalUser.ID { if newShare.TargetID != normalUser.ID {
t.Fatalf("expected member userId %s, got %s", normalUser.ID, newMember.UserID) t.Fatalf("expected share targetId %s, got %s", normalUser.ID, newShare.TargetID)
} }
// 3. User checks their spaces; the shared space must be listed // 3. User checks their lists; the shared list must be listed
req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil) req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil)
req.Header.Set("Authorization", "Bearer "+userToken) req.Header.Set("Authorization", "Bearer "+userToken)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK { 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 var userLists []domain.SharedList
_ = json.Unmarshal(rec.Body.Bytes(), &userSpaces) _ = json.Unmarshal(rec.Body.Bytes(), &userLists)
found := false found := false
for _, s := range userSpaces { for _, l := range userLists {
if s.ID == createdSpace.ID { if l.ID == createdList.ID {
found = true found = true
break break
} }
} }
if !found { 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 // 4. Admin removes user share from list
req = httptest.NewRequest(http.MethodDelete, "/api/v1/spaces/"+createdSpace.ID+"/members/"+normalUser.ID, nil) req = httptest.NewRequest(http.MethodDelete, "/api/v1/lists/"+createdList.ID+"/shares/"+newShare.ID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Authorization", "Bearer "+adminToken)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK { 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") team, err := store.CreateTeam("", "WG Team", "Haushalt")
if err != nil { if err != nil {
t.Fatalf("failed to create team: %v", err) t.Fatalf("failed to create team: %v", err)
} }
_ = store.AddTeamMember(team.ID, normalUser.ID, "member") _ = store.AddTeamMember(team.ID, normalUser.ID, "member")
addTeamBody := []byte(`{"teamId":"` + team.ID + `","role":"editor"}`) addTeamBody := []byte(`{"targetType":"team","targetId":"` + team.ID + `","role":"editor"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addTeamBody)) req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addTeamBody))
req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK { 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 // User should see the list again because their team was added
req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil) req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil)
req.Header.Set("Authorization", "Bearer "+userToken) req.Header.Set("Authorization", "Bearer "+userToken)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK { 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 userLists = nil
_ = json.Unmarshal(rec.Body.Bytes(), &userSpaces) _ = json.Unmarshal(rec.Body.Bytes(), &userLists)
found = false found = false
for _, s := range userSpaces { for _, l := range userLists {
if s.ID == createdSpace.ID { if l.ID == createdList.ID {
found = true found = true
break break
} }
} }
if !found { 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)
} }
} }
+9 -29
View File
@@ -324,40 +324,18 @@ type TagDetails struct {
TotalMatches int `json:"totalMatches"` TotalMatches int `json:"totalMatches"`
} }
type SpaceMember struct { type ListShare 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 {
ID string `json:"id"` ID string `json:"id"`
SpaceID string `json:"spaceId"` ListID string `json:"listId"`
TargetType string `json:"targetType"` // team | user TargetType string `json:"targetType"` // team | user
TargetID string `json:"targetId"` TargetID string `json:"targetId"`
TargetName string `json:"targetName,omitempty"` TargetName string `json:"targetName,omitempty"`
Email string `json:"email,omitempty"`
Role string `json:"role"` // editor | viewer Role string `json:"role"` // editor | viewer
CreatedBy string `json:"createdBy"` CreatedBy string `json:"createdBy"`
CreatedAt string `json:"createdAt"` 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 { type ListItem struct {
ID string `json:"id"` ID string `json:"id"`
ListID string `json:"listId"` ListID string `json:"listId"`
@@ -374,11 +352,15 @@ type ListItem struct {
type SharedList struct { type SharedList struct {
ID string `json:"id"` ID string `json:"id"`
SpaceID string `json:"spaceId"` UserID string `json:"userId"`
OwnerName string `json:"ownerName,omitempty"`
IsOwner bool `json:"isOwner"`
Role string `json:"role,omitempty"` // editor | viewer
Title string `json:"title"` Title string `json:"title"`
Icon string `json:"icon"` Icon string `json:"icon"`
ListType string `json:"listType"` // shopping | checklist | vacation ListType string `json:"listType"` // shopping | checklist | vacation
Items []ListItem `json:"items"` Items []ListItem `json:"items"`
Shares []ListShare `json:"shares,omitempty"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
} }
@@ -397,8 +379,7 @@ type APIKey struct {
type WSMessage struct { type WSMessage struct {
Event string `json:"event"` // ITEM_CREATED | ITEM_UPDATED | ITEM_TOGGLED | ITEM_DELETED | LIST_CLEARED | USER_PRESENCE 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"`
ListID string `json:"list_id,omitempty"`
Payload interface{} `json:"payload"` Payload interface{} `json:"payload"`
Timestamp string `json:"timestamp"` Timestamp string `json:"timestamp"`
} }
@@ -419,7 +400,6 @@ type FullExport struct {
User User `json:"user"` User User `json:"user"`
Thoughts []Thought `json:"thoughts"` Thoughts []Thought `json:"thoughts"`
Documents []Document `json:"documents"` Documents []Document `json:"documents"`
Spaces []Space `json:"spaces"`
Lists []SharedList `json:"lists"` Lists []SharedList `json:"lists"`
TaskLists []TaskList `json:"taskLists,omitempty"` TaskLists []TaskList `json:"taskLists,omitempty"`
} }
+231 -415
View File
@@ -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 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 ( CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -259,16 +237,32 @@ func (s *Store) migrate() error {
CREATE TABLE IF NOT EXISTS lists ( CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
space_id TEXT NOT NULL, user_id TEXT NOT NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '🛒', icon TEXT NOT NULL DEFAULT '🛒',
list_type TEXT NOT NULL DEFAULT 'shopping', list_type TEXT NOT NULL DEFAULT 'shopping',
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
created_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 ( CREATE TABLE IF NOT EXISTS list_items (
id TEXT PRIMARY KEY, 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_team ON doc_team_shares(team_id);",
"CREATE INDEX IF NOT EXISTS idx_doc_team_shares_doc ON doc_team_shares(doc_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));", "ALTER TABLE lists ADD COLUMN user_id TEXT NOT NULL DEFAULT '';",
"CREATE INDEX IF NOT EXISTS idx_space_team_shares_team ON space_team_shares(team_id);", "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_space_team_shares_space ON space_team_shares(space_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 { for _, q := range columnMigrations {
_, _ = s.db.Exec(q) _, _ = 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 // 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`) _, _ = 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,14 +442,11 @@ func (s *Store) SeedStarterContent(user *domain.User) error {
_, _ = s.UpdateThoughtTriage(user.ID, keptTask.ID, "keep", "task", "", now[:10], "") _, _ = 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) shopList, err := s.CreateList(user.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping")
if err == nil && familySpace != nil {
shopList, err := s.CreateList(familySpace.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping")
if err == nil && shopList != nil { if err == nil && shopList != nil {
_, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0) _, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0)
_, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1) _, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1)
} }
}
starterBlocks := []domain.DocumentBlock{ starterBlocks := []domain.DocumentBlock{
{ID: "blk_1", Type: "callout", Content: "Willkommen in deinem MindSift Workspace! Hier wachsen flüchtige Gedanken aus deiner Inbox zu strukturierten Projekten heran."}, {ID: "blk_1", Type: "callout", Content: "Willkommen in deinem MindSift Workspace! Hier wachsen flüchtige Gedanken aus deiner Inbox zu strukturierten Projekten heran."},
@@ -692,12 +689,8 @@ func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) {
SELECT tm2.user_id FROM team_members tm1 SELECT tm2.user_id FROM team_members tm1
JOIN team_members tm2 ON tm1.team_id = tm2.team_id JOIN team_members tm2 ON tm1.team_id = tm2.team_id
WHERE tm1.user_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 { if err != nil {
return []domain.User{*viewer}, nil return []domain.User{*viewer}, nil
@@ -879,15 +872,14 @@ func (s *Store) accessibleTaskListIDsQuery() string {
SELECT task_list_id FROM task_list_shares SELECT task_list_id FROM task_list_shares
WHERE (target_type = 'user' AND target_id = ?) 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 (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) { func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
row := s.db.QueryRow( 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 `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()+`))`, 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) return scanThought(row)
} }
@@ -940,7 +932,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
AND task_list_id = ? AND task_list_id = ?
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) 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`, 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 { } else {
rows, err = s.db.Query( 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()+`)) WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) 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`, 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 { if err != nil {
@@ -2190,339 +2182,9 @@ func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) {
return users, nil return users, nil
} }
// --- SPACES & REALTIME LISTS --- // --- STANDALONE REALTIME LISTS & SHARING ---
func (s *Store) CreateSpace(ownerID, name, icon, description string, isPersonal bool) (*domain.Space, error) { func (s *Store) CreateList(userID, title, icon, listType string) (*domain.SharedList, 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) {
if strings.TrimSpace(title) == "" { if strings.TrimSpace(title) == "" {
return nil, errors.New("list title required") 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() now := domain.NowISO()
lst := &domain.SharedList{ lst := &domain.SharedList{
ID: domain.NewID("lst"), ID: domain.NewID("lst"),
SpaceID: spaceID, UserID: userID,
IsOwner: true,
Role: "editor",
Title: strings.TrimSpace(title), Title: strings.TrimSpace(title),
Icon: icon, Icon: icon,
ListType: listType, ListType: listType,
Items: []domain.ListItem{}, Items: []domain.ListItem{},
Shares: []domain.ListShare{},
UpdatedAt: now, UpdatedAt: now,
CreatedAt: now, CreatedAt: now,
} }
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO lists (id, space_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, `INSERT INTO lists (id, user_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
lst.ID, lst.SpaceID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt, lst.ID, lst.UserID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&lst.OwnerName)
return lst, nil return lst, nil
} }
func (s *Store) GetListsForSpace(spaceID string) ([]domain.SharedList, error) { func (s *Store) AddListShare(currentUserID, listID, targetType, targetID, role string) (*domain.ListShare, error) {
rows, err := s.db.Query( if targetType != "team" && targetType != "user" {
`SELECT id, space_id, title, icon, list_type, updated_at, created_at return nil, errors.New("ungültiger Freigabetyp")
FROM lists WHERE space_id = ? ORDER BY created_at ASC`, }
spaceID, 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 { if err != nil {
return nil, err 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 = ?`,
for rows.Next() { listID, targetType, targetID).Scan(&share.ID, &share.CreatedAt)
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)
}
}
_ = rows.Close()
for i := range lists { if targetType == "team" {
lists[i].Items, _ = s.GetListItems(lists[i].ID) _ = 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 lists, nil 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 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)
}
}
return shares, nil
} }
func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) { func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
rows, err := s.db.Query( 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 FROM lists l
LEFT JOIN space_members sm ON sm.space_id = l.space_id AND sm.user_id = ? LEFT JOIN users u ON u.id = l.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 = ?) LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
WHERE sm.user_id = ? OR sts.team_id IS NOT NULL (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`, ORDER BY l.updated_at DESC`,
userID, userID, userID, userID, userID, userID, userID,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -2596,7 +2366,12 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
lists := make([]domain.SharedList, 0) lists := make([]domain.SharedList, 0)
for rows.Next() { for rows.Next() {
var l domain.SharedList 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) lists = append(lists, l)
} }
} }
@@ -2604,25 +2379,68 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
for i := range lists { for i := range lists {
lists[i].Items, _ = s.GetListItems(lists[i].ID) lists[i].Items, _ = s.GetListItems(lists[i].ID)
lists[i].Shares, _ = s.GetListShares(lists[i].ID)
} }
return lists, nil 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 var l domain.SharedList
err := s.db.QueryRow( var ownerName sql.NullString
`SELECT id, space_id, title, icon, list_type, updated_at, created_at FROM lists WHERE id = ?`, var effectiveRole string
listID,
).Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt) 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 { if err != nil {
return nil, err return nil, err
} }
l.OwnerName = ownerName.String
l.IsOwner = (userID == "" || l.UserID == userID)
l.Role = effectiveRole
l.Items, _ = s.GetListItems(l.ID) l.Items, _ = s.GetListItems(l.ID)
l.Shares, _ = s.GetListShares(l.ID)
return &l, nil return &l, nil
} }
func (s *Store) DeleteList(listID string) error { func (s *Store) DeleteList(userID, listID string) error {
_, err := s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID) 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 return err
} }
@@ -2829,9 +2647,11 @@ func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error)
_ = s.db.QueryRow(` _ = s.db.QueryRow(`
SELECT COUNT(DISTINCT l.id) SELECT COUNT(DISTINCT l.id)
FROM lists l FROM lists l
LEFT JOIN space_members sm ON sm.space_id = l.space_id AND sm.user_id = ? LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
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 = ?) (ls.target_type = 'user' AND ls.target_id = ?)
WHERE sm.user_id = ? OR sts.team_id IS NOT NULL`, 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) userID, userID, userID).Scan(&st.ListsCount)
// Count open (uncompleted) To-Dos across accessible workspace documents (owned or shared) // 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) thoughts, _ := s.GetThoughts(userID, "all", 5000)
docs, _ := s.GetDocuments(userID) docs, _ := s.GetDocuments(userID)
spaces, _ := s.GetSpacesForUser(userID)
lists, _ := s.GetAllListsForUser(userID) lists, _ := s.GetAllListsForUser(userID)
taskLists, _ := s.GetTaskLists(userID) taskLists, _ := s.GetTaskLists(userID)
@@ -2918,7 +2737,6 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
User: *user, User: *user,
Thoughts: thoughts, Thoughts: thoughts,
Documents: docs, Documents: docs,
Spaces: spaces,
Lists: lists, Lists: lists,
TaskLists: taskLists, TaskLists: taskLists,
}, nil }, nil
@@ -3127,9 +2945,8 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
WHERE (target_type = 'user' AND target_id = ?) 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 (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`, ORDER BY updated_at DESC`,
userID, userID, userID, userID, userID, userID, userID, userID, userID,
) )
if err != nil { if err != nil {
return []domain.TaskList{}, err 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 = ?) 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 (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) ).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
+17 -21
View File
@@ -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) t.Fatalf("expected converted document, got status=%s type=%s ref=%s", triaged.Status, triaged.ConvertedType, triaged.ConvertedRefID)
} }
// 3. Test Shared Space & Shopping List // 3. Test Standalone Shopping List & Sharing
sp, err := store.CreateSpace(user.ID, "Test WG", "🍕", "Test Space", false) lst, err := store.CreateList(user.ID, "REWE Einkauf", "🛒", "shopping")
if err != nil {
t.Fatalf("CreateSpace failed: %v", err)
}
lst, err := store.CreateList(sp.ID, "REWE Einkauf", "🛒", "shopping")
if err != nil { if err != nil {
t.Fatalf("CreateList failed: %v", err) t.Fatalf("CreateList failed: %v", err)
} }
@@ -66,26 +62,26 @@ func TestStoreLifecycle(t *testing.T) {
t.Fatalf("ToggleListItem failed: %v", err) t.Fatalf("ToggleListItem failed: %v", err)
} }
// Test AddSpaceMember & RemoveSpaceMember // Test AddListShare & RemoveListShare
u2, err := store.CreateUser("collab_space@example.com", "hash", "Collab User", "member") u2, err := store.CreateUser("collab_space@example.com", "hash", "Collab User", "member")
if err != nil { if err != nil {
t.Fatalf("CreateUser u2 failed: %v", err) t.Fatalf("CreateUser u2 failed: %v", err)
} }
member, err := store.AddSpaceMember(user.ID, sp.ID, u2.ID, "editor") share, err := store.AddListShare(user.ID, lst.ID, "user", u2.ID, "editor")
if err != nil || member.UserID != u2.ID { if err != nil || share.TargetID != u2.ID {
t.Fatalf("AddSpaceMember failed: %v", err) t.Fatalf("AddListShare failed: %v", err)
} }
members, err := store.GetSpaceMembers(sp.ID) shares, err := store.GetListShares(lst.ID)
if err != nil || len(members) != 2 { if err != nil || len(shares) != 1 {
t.Fatalf("expected 2 members, got %d", len(members)) 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 { if err != nil {
t.Fatalf("RemoveSpaceMember failed: %v", err) t.Fatalf("RemoveListShare failed: %v", err)
} }
membersAfter, _ := store.GetSpaceMembers(sp.ID) sharesAfter, _ := store.GetListShares(lst.ID)
if len(membersAfter) != 1 { if len(sharesAfter) != 0 {
t.Fatalf("expected 1 member after remove, got %d", len(membersAfter)) t.Fatalf("expected 0 shares after remove, got %d", len(sharesAfter))
} }
// 4. Test Webhook API Key generation & validation // 4. Test Webhook API Key generation & validation
@@ -99,7 +95,7 @@ func TestStoreLifecycle(t *testing.T) {
} }
// 5. Test TaskLists CRUD // 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 == "" { if err != nil || taskList.ID == "" {
t.Fatalf("CreateTaskList failed: %v", err) t.Fatalf("CreateTaskList failed: %v", err)
} }
@@ -160,8 +156,8 @@ func TestStoreLifecycle(t *testing.T) {
} }
// Share with user2 as "read" // Share with user2 as "read"
share, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read") docShare, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read")
if err != nil || share.Permission != "read" { if err != nil || docShare.Permission != "read" {
t.Fatalf("CreateDocShare failed: %v", err) t.Fatalf("CreateDocShare failed: %v", err)
} }
+24 -18
View File
@@ -22,7 +22,7 @@ var upgrader = websocket.Upgrader{
type Client struct { type Client struct {
hub *Hub hub *Hub
conn *websocket.Conn conn *websocket.Conn
spaceID string roomID string
userID string userID string
send chan []byte send chan []byte
} }
@@ -37,7 +37,7 @@ type docLockItem struct {
type Hub struct { type Hub struct {
mu sync.RWMutex mu sync.RWMutex
spaceRooms map[string]map[*Client]bool rooms map[string]map[*Client]bool
userRooms map[string]map[*Client]bool userRooms map[string]map[*Client]bool
allClients map[*Client]bool allClients map[*Client]bool
locks map[string]*docLockItem locks map[string]*docLockItem
@@ -45,7 +45,7 @@ type Hub struct {
func NewHub() *Hub { func NewHub() *Hub {
h := &Hub{ h := &Hub{
spaceRooms: make(map[string]map[*Client]bool), rooms: make(map[string]map[*Client]bool),
userRooms: make(map[string]map[*Client]bool), userRooms: make(map[string]map[*Client]bool),
allClients: make(map[*Client]bool), allClients: make(map[*Client]bool),
locks: make(map[string]*docLockItem), locks: make(map[string]*docLockItem),
@@ -57,11 +57,11 @@ func NewHub() *Hub {
func (h *Hub) Register(c *Client) { func (h *Hub) Register(c *Client) {
h.mu.Lock() h.mu.Lock()
h.allClients[c] = true h.allClients[c] = true
if c.spaceID != "" { if c.roomID != "" {
if h.spaceRooms[c.spaceID] == nil { if h.rooms[c.roomID] == nil {
h.spaceRooms[c.spaceID] = make(map[*Client]bool) 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 c.userID != "" && c.userID != "anonymous" {
if h.userRooms[c.userID] == nil { if h.userRooms[c.userID] == nil {
@@ -72,8 +72,8 @@ func (h *Hub) Register(c *Client) {
onlineCount := len(h.allClients) onlineCount := len(h.allClients)
h.mu.Unlock() h.mu.Unlock()
if c.spaceID != "" { if c.roomID != "" {
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{ h.Broadcast(c.roomID, "", "USER_PRESENCE", map[string]interface{}{
"onlineCount": onlineCount, "onlineCount": onlineCount,
}) })
} }
@@ -82,11 +82,11 @@ func (h *Hub) Register(c *Client) {
func (h *Hub) Unregister(c *Client) { func (h *Hub) Unregister(c *Client) {
h.mu.Lock() h.mu.Lock()
delete(h.allClients, c) delete(h.allClients, c)
if c.spaceID != "" { if c.roomID != "" {
if room, ok := h.spaceRooms[c.spaceID]; ok { if room, ok := h.rooms[c.roomID]; ok {
delete(room, c) delete(room, c)
if len(room) == 0 { 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{}) { func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
msg := domain.WSMessage{ msg := domain.WSMessage{
Event: event, Event: event,
SpaceID: spaceID,
ListID: listID, ListID: listID,
Payload: payload, Payload: payload,
Timestamp: domain.NowISO(), Timestamp: domain.NowISO(),
@@ -310,10 +309,12 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
room, ok := h.spaceRooms[spaceID] targetRoom := listID
if !ok { if targetRoom == "" {
return targetRoom = spaceID
} }
if room, ok := h.rooms[targetRoom]; ok {
for client := range room { for client := range room {
select { select {
case client.send <- data: case client.send <- data:
@@ -322,8 +323,13 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
} }
} }
} }
}
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) conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
log.Printf("[ws] upgrade error: %v", err) 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{ client := &Client{
hub: h, hub: h,
conn: conn, conn: conn,
spaceID: spaceID, roomID: roomID,
userID: userID, userID: userID,
send: make(chan []byte, 128), send: make(chan []byte, 128),
} }