Dev #4
No files matched your search
@@ -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
@@ -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)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user