feat(tasks): enable sharing task lists with teams and users
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m6s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m6s
This commit is contained in:
1 parent
cfc0477b5c
commit
0afa2f9c2d
7 files changed
+648
-135
No files matched your search
@@ -16,8 +16,10 @@ import {
|
||||
Share2,
|
||||
Users,
|
||||
CheckSquare,
|
||||
User as UserIcon,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { TaskList, Thought, Space, SubTask } from '../../lib/types';
|
||||
import { TaskList, Thought, SubTask, Team, User, TaskListShare } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
|
||||
const getInitials = (name?: string): string => {
|
||||
@@ -45,7 +47,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const [tasks, setTasks] = useState<Thought[]>([]);
|
||||
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
|
||||
const [selectedListId, setSelectedListId] = useState<string>('all');
|
||||
const [spaces, setSpaces] = useState<Space[]>([]);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [collaborators, setCollaborators] = useState<User[]>([]);
|
||||
const [discarded, setDiscarded] = useState<Thought[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -57,12 +60,14 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const [showNewListModal, setShowNewListModal] = useState(false);
|
||||
const [newListTitle, setNewListTitle] = useState('');
|
||||
const [newListIcon, setNewListIcon] = useState('📋');
|
||||
const [newListSpaceId, setNewListSpaceId] = useState('');
|
||||
const [newListShareTarget, setNewListShareTarget] = useState('');
|
||||
|
||||
// Share TaskList modal state
|
||||
const [showShareListModal, setShowShareListModal] = useState(false);
|
||||
const [sharingTaskList, setSharingTaskList] = useState<TaskList | null>(null);
|
||||
const [shareTargetSpaceId, setShareTargetSpaceId] = useState('');
|
||||
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
|
||||
const [selectedShareTargetId, setSelectedShareTargetId] = useState('');
|
||||
const [shareLoading, setShareLoading] = useState(false);
|
||||
|
||||
// Active notes inline edit ID
|
||||
const [activeNotesTaskId, setActiveNotesTaskId] = useState<string | null>(null);
|
||||
@@ -75,15 +80,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [allTasks, allLists, allSpaces, allDiscarded] = await Promise.all([
|
||||
const [allTasks, allLists, allTeams, allCollabs, allDiscarded] = await Promise.all([
|
||||
api.getTasks(selectedListId === 'all' ? undefined : selectedListId).catch(() => []),
|
||||
api.getTaskLists().catch(() => []),
|
||||
api.getSpaces().catch(() => []),
|
||||
api.getUserTeams().catch(() => []),
|
||||
api.getCollaborators().catch(() => []),
|
||||
api.getThoughts('discarded').catch(() => []),
|
||||
]);
|
||||
setTasks(Array.isArray(allTasks) ? allTasks : []);
|
||||
setTaskLists(Array.isArray(allLists) ? allLists : []);
|
||||
setSpaces(Array.isArray(allSpaces) ? allSpaces : []);
|
||||
setTeams(Array.isArray(allTeams) ? allTeams : []);
|
||||
setCollaborators(Array.isArray(allCollabs) ? allCollabs : []);
|
||||
setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []);
|
||||
} catch {
|
||||
// Prevent unhandled promise rejection from crashing view
|
||||
@@ -424,15 +431,27 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const title = newListTitle.trim();
|
||||
if (!title) return;
|
||||
try {
|
||||
let teamId: string | undefined = undefined;
|
||||
let userId: string | undefined = undefined;
|
||||
if (newListShareTarget.startsWith('team:')) {
|
||||
teamId = newListShareTarget.replace('team:', '');
|
||||
} else if (newListShareTarget.startsWith('user:')) {
|
||||
userId = newListShareTarget.replace('user:', '');
|
||||
}
|
||||
const created = await api.createTaskList({
|
||||
title,
|
||||
icon: newListIcon,
|
||||
spaceId: newListSpaceId,
|
||||
teamId: teamId,
|
||||
});
|
||||
setTaskLists((prev) => [...prev, created]);
|
||||
if (userId && created?.id) {
|
||||
await api.addTaskListShare(created.id, { targetType: 'user', targetId: userId });
|
||||
}
|
||||
const freshLists = await api.getTaskLists();
|
||||
setTaskLists(freshLists);
|
||||
setSelectedListId(created.id);
|
||||
setShowNewListModal(false);
|
||||
setNewListTitle('');
|
||||
setNewListShareTarget('');
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Erstellen der Aufgabenliste');
|
||||
}
|
||||
@@ -456,25 +475,47 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
const handleOpenShareModal = (tl: TaskList) => {
|
||||
setSharingTaskList(tl);
|
||||
setShareTargetSpaceId(tl.spaceId || '');
|
||||
setSelectedShareTargetId('');
|
||||
setShowShareListModal(true);
|
||||
};
|
||||
|
||||
const handleSaveShareList = async () => {
|
||||
if (!sharingTaskList) return;
|
||||
const handleAddShare = async () => {
|
||||
if (!sharingTaskList || !selectedShareTargetId) return;
|
||||
setShareLoading(true);
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
const updated = await api.updateTaskList(sharingTaskList.id, {
|
||||
spaceId: shareTargetSpaceId,
|
||||
await api.addTaskListShare(sharingTaskList.id, {
|
||||
targetType: addShareType,
|
||||
targetId: selectedShareTargetId,
|
||||
});
|
||||
setTaskLists((prev) =>
|
||||
prev.map((l) => (l.id === updated.id ? { ...l, ...updated } : l))
|
||||
);
|
||||
setShowShareListModal(false);
|
||||
setSharingTaskList(null);
|
||||
const freshLists = await api.getTaskLists();
|
||||
setTaskLists(freshLists);
|
||||
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
|
||||
if (updated) setSharingTaskList(updated);
|
||||
setSelectedShareTargetId('');
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Speichern der Freigabe');
|
||||
alert(err.message || 'Fehler beim Hinzufügen der Freigabe');
|
||||
} finally {
|
||||
setShareLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveShare = async (shareId: string) => {
|
||||
if (!sharingTaskList) return;
|
||||
setShareLoading(true);
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
await api.removeTaskListShare(sharingTaskList.id, shareId);
|
||||
const freshLists = await api.getTaskLists();
|
||||
setTaskLists(freshLists);
|
||||
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
|
||||
if (updated) setSharingTaskList(updated);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Entfernen der Freigabe');
|
||||
} finally {
|
||||
setShareLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -491,7 +532,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
return unique;
|
||||
}, [tasks]);
|
||||
const safeTaskLists = Array.isArray(taskLists) ? taskLists : [];
|
||||
const safeSpaces = Array.isArray(spaces) ? spaces : [];
|
||||
const safeTeams = Array.isArray(teams) ? teams : [];
|
||||
const safeCollaborators = Array.isArray(collaborators) ? collaborators : [];
|
||||
const safeDiscarded = Array.isArray(discarded) ? discarded : [];
|
||||
|
||||
const openTasks = safeTasks.filter((t) => !t.isCompleted);
|
||||
@@ -547,8 +589,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : tl.spaceId ? (
|
||||
<span title="Geteilt mit Team/Space">
|
||||
) : (tl.teamId || (tl.shares && tl.shares.length > 0)) ? (
|
||||
<span title="Geteilt mit Team oder Benutzern">
|
||||
<Share2 className="w-2.5 h-2.5 text-accent ml-0.5" />
|
||||
</span>
|
||||
) : null}
|
||||
@@ -594,7 +636,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<div
|
||||
onClick={() => handleOpenShareModal(currentSelectedList)}
|
||||
className="flex items-center -space-x-1.5 cursor-pointer hover:opacity-90 transition-opacity pl-0.5"
|
||||
title={`Geteilt mit ${currentSelectedList.members.length} Mitgliedern (${currentSelectedList.spaceName || 'Team'}). Klick zum Verwalten.`}
|
||||
title={`Geteilt mit ${currentSelectedList.members.length} Mitgliedern (${currentSelectedList.teamName || 'Team'}). Klick zum Verwalten.`}
|
||||
>
|
||||
{currentSelectedList.members.slice(0, 3).map((m) => (
|
||||
<div
|
||||
@@ -729,25 +771,36 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{safeSpaces.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs text-muted mb-1">
|
||||
Geteilt mit Space (optional)
|
||||
Freigabe (optional)
|
||||
</label>
|
||||
<select
|
||||
value={newListSpaceId}
|
||||
onChange={(e) => setNewListSpaceId(e.target.value)}
|
||||
value={newListShareTarget}
|
||||
onChange={(e) => setNewListShareTarget(e.target.value)}
|
||||
className="w-full h-8 input-field px-2 text-xs"
|
||||
>
|
||||
<option value="">Privat (nur ich)</option>
|
||||
{safeSpaces.map((sp) => (
|
||||
<option key={sp.id} value={sp.id}>
|
||||
{sp.icon} {sp.name}
|
||||
<option value="">🔒 Privat (nur für mich)</option>
|
||||
{safeTeams.length > 0 && (
|
||||
<optgroup label="Teams & Gruppen">
|
||||
{safeTeams.map((tm) => (
|
||||
<option key={tm.id} value={`team:${tm.id}`}>
|
||||
👥 {tm.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{safeCollaborators.length > 0 && (
|
||||
<optgroup label="Einzelne Benutzer">
|
||||
{safeCollaborators.map((u) => (
|
||||
<option key={u.id} value={`user:${u.id}`}>
|
||||
👤 {u.displayName || u.email}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||
<button
|
||||
@@ -771,10 +824,19 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<div className="fixed inset-0 modal-overlay z-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md panel-surface shadow-panel p-4 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
||||
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5">
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
<span>Liste teilen: {sharingTaskList.title}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">{sharingTaskList.icon || '📋'}</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-main">
|
||||
Liste teilen: {sharingTaskList.title}
|
||||
</h3>
|
||||
<p className="text-[11px] text-muted">
|
||||
{sharingTaskList.shares && sharingTaskList.shares.length > 0
|
||||
? `${sharingTaskList.shares.length} Freigabe(n) aktiv`
|
||||
: 'Aktuell privat (nur für dich)'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -787,76 +849,178 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
{/* Active Shares */}
|
||||
<div>
|
||||
<label className="block text-xs text-muted mb-1 font-medium">
|
||||
Freigeben für Bereich / Team:
|
||||
<label className="block text-xs font-semibold text-main mb-1.5">
|
||||
Aktive Freigaben:
|
||||
</label>
|
||||
<select
|
||||
value={shareTargetSpaceId}
|
||||
onChange={(e) => setShareTargetSpaceId(e.target.value)}
|
||||
className="w-full h-8 px-2.5 input-field text-xs bg-panel"
|
||||
{sharingTaskList.shares && sharingTaskList.shares.length > 0 ? (
|
||||
<div className="space-y-1.5 max-h-40 overflow-y-auto pr-1">
|
||||
{sharingTaskList.shares.map((sh) => (
|
||||
<div
|
||||
key={sh.id}
|
||||
className="p-2 bg-canvas border border-border rounded-card flex items-center justify-between text-xs"
|
||||
>
|
||||
<option value="">🔒 Privat (Nicht geteilt - nur für mich)</option>
|
||||
{safeSpaces.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.icon} {s.name} ({s.members?.length || 1} Mitglieder)
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-6 h-6 rounded-pill bg-accent/20 text-accent flex items-center justify-center text-xs flex-shrink-0">
|
||||
{sh.targetType === 'team' ? (
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<UserIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-main truncate">
|
||||
{sh.targetName || (sh.targetType === 'team' ? 'Team' : sh.email || 'Benutzer')}
|
||||
</p>
|
||||
{sh.email && sh.email !== sh.targetName && (
|
||||
<p className="text-[10px] text-muted truncate">{sh.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="badge-neutral text-[10px]">
|
||||
{sh.targetType === 'team' ? 'Team' : 'Benutzer'}
|
||||
</span>
|
||||
{sharingTaskList.isOwner !== false && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveShare(sh.id)}
|
||||
disabled={shareLoading}
|
||||
className="text-muted hover:text-status-err p-1 transition-colors"
|
||||
title="Freigabe entfernen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2.5 bg-canvas border border-border rounded-card text-xs text-muted flex items-center gap-2">
|
||||
<Lock className="w-3.5 h-3.5 text-muted flex-shrink-0" />
|
||||
<span>Diese Liste ist privat. Füge unten ein Team oder einen Benutzer hinzu.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add New Share */}
|
||||
{sharingTaskList.isOwner !== false && (
|
||||
<div className="space-y-2 pt-2 border-t border-border">
|
||||
<label className="block text-xs font-semibold text-main">
|
||||
Neue Freigabe hinzufügen:
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddShareType('team');
|
||||
setSelectedShareTargetId('');
|
||||
}}
|
||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
|
||||
addShareType === 'team'
|
||||
? 'bg-panel border-accent text-main'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-3 h-3 text-accent" />
|
||||
<span>Team / Gruppe</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddShareType('user');
|
||||
setSelectedShareTargetId('');
|
||||
}}
|
||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
|
||||
addShareType === 'user'
|
||||
? 'bg-panel border-accent text-main'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<UserIcon className="w-3 h-3 text-accent" />
|
||||
<span>Einzelner Benutzer</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{addShareType === 'team' ? (
|
||||
<select
|
||||
value={selectedShareTargetId}
|
||||
onChange={(e) => setSelectedShareTargetId(e.target.value)}
|
||||
className="flex-1 h-8 px-2.5 input-field text-xs bg-panel"
|
||||
>
|
||||
<option value="">-- Team / Gruppe auswählen --</option>
|
||||
{safeTeams
|
||||
.filter((t) => !(sharingTaskList.shares || []).some((s) => s.targetType === 'team' && s.targetId === t.id))
|
||||
.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
👥 {t.name} ({t.members?.length || 1} Mitglieder)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<select
|
||||
value={selectedShareTargetId}
|
||||
onChange={(e) => setSelectedShareTargetId(e.target.value)}
|
||||
className="flex-1 h-8 px-2.5 input-field text-xs bg-panel"
|
||||
>
|
||||
<option value="">-- Benutzer auswählen --</option>
|
||||
{safeCollaborators
|
||||
.filter((c) => !(sharingTaskList.shares || []).some((s) => s.targetType === 'user' && s.targetId === c.id))
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
👤 {c.displayName || c.email} ({c.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddShare}
|
||||
disabled={!selectedShareTargetId || shareLoading}
|
||||
className="btn-primary h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Freigeben</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members preview */}
|
||||
{(() => {
|
||||
const selectedSpace = safeSpaces.find((s) => s.id === shareTargetSpaceId);
|
||||
const members = selectedSpace?.members || [];
|
||||
if (members.length > 0) {
|
||||
return (
|
||||
<div className="space-y-2 pt-2 border-t border-border">
|
||||
{/* Persons with access summary */}
|
||||
{sharingTaskList.members && sharingTaskList.members.length > 0 && (
|
||||
<div className="space-y-1.5 pt-2 border-t border-border">
|
||||
<p className="text-xs font-semibold text-main">
|
||||
Personen mit Zugriff ({members.length}):
|
||||
Personen mit Zugriff ({sharingTaskList.members.length}):
|
||||
</p>
|
||||
<div className="max-h-48 overflow-y-auto space-y-1.5 pr-1">
|
||||
{members.map((m) => (
|
||||
<div className="max-h-36 overflow-y-auto space-y-1 pr-1">
|
||||
{sharingTaskList.members.map((m) => (
|
||||
<div
|
||||
key={m.userId}
|
||||
className="p-2 bg-canvas border border-border rounded-card flex items-center justify-between text-xs"
|
||||
className="p-1.5 bg-canvas border border-border rounded-card flex items-center justify-between text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-pill bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-5 h-5 rounded-pill bg-accent/20 text-accent font-bold text-[9px] flex items-center justify-center flex-shrink-0">
|
||||
{getInitials(m.displayName || m.email)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-main">{m.displayName || m.email}</p>
|
||||
<p className="text-[10px] text-muted">{m.email}</p>
|
||||
<span className="font-medium text-main truncate">
|
||||
{m.displayName || m.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="badge-neutral text-[10px]">
|
||||
<span className="badge-neutral text-[10px] flex-shrink-0">
|
||||
{m.role === 'owner' ? 'Eigentümer' : m.role === 'editor' ? 'Bearbeiter' : 'Mitglied'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (shareTargetSpaceId) {
|
||||
return (
|
||||
<p className="text-xs text-muted italic">Keine Mitglieder in diesem Bereich.</p>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="p-3 bg-canvas border border-border rounded-card text-xs text-muted space-y-1">
|
||||
<p className="font-medium text-main">Diese Aufgabenliste ist privat.</p>
|
||||
<p className="text-[11px] text-faint">
|
||||
Wähle oben einen Familien- oder Team-Space aus, um die Liste und alle enthaltenen Aufgaben automatisch mit den Mitgliedern zu teilen.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-border">
|
||||
<div className="flex justify-end pt-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -865,14 +1029,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}}
|
||||
className="btn-action text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveShareList}
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
Freigabe speichern
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+27
-1
@@ -15,6 +15,7 @@ import {
|
||||
SystemSettings,
|
||||
Team,
|
||||
TaskList,
|
||||
TaskListShare,
|
||||
Thought,
|
||||
SubTask,
|
||||
TagDetails,
|
||||
@@ -336,13 +337,18 @@ export const api = {
|
||||
// --- TASK LISTS ---
|
||||
async getTaskLists(): Promise<TaskList[]> {
|
||||
const res = await request<TaskList[]>('/api/v1/task-lists');
|
||||
return ensureArray(res);
|
||||
return ensureArray(res).map((tl) => ({
|
||||
...tl,
|
||||
members: ensureArray(tl.members),
|
||||
shares: ensureArray(tl.shares),
|
||||
}));
|
||||
},
|
||||
|
||||
async createTaskList(payload: {
|
||||
title: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
teamId?: string;
|
||||
spaceId?: string;
|
||||
}): Promise<TaskList> {
|
||||
return request<TaskList>('/api/v1/task-lists', {
|
||||
@@ -357,6 +363,7 @@ export const api = {
|
||||
title?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
teamId?: string;
|
||||
spaceId?: string;
|
||||
}
|
||||
): Promise<TaskList> {
|
||||
@@ -370,6 +377,25 @@ export const api = {
|
||||
await request(`/api/v1/task-lists/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async getTaskListShares(id: string): Promise<TaskListShare[]> {
|
||||
const res = await request<TaskListShare[]>(`/api/v1/task-lists/${id}/shares`);
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async addTaskListShare(
|
||||
id: string,
|
||||
payload: { targetType: 'team' | 'user'; targetId: string }
|
||||
): Promise<TaskListShare> {
|
||||
return request<TaskListShare>(`/api/v1/task-lists/${id}/shares`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async removeTaskListShare(id: string, shareId: string): Promise<void> {
|
||||
await request(`/api/v1/task-lists/${id}/shares/${shareId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// --- WORKSPACE FOLDERS ---
|
||||
async getFolders(): Promise<string[]> {
|
||||
const res = await request<string[]>('/api/v1/folders');
|
||||
|
||||
@@ -64,13 +64,34 @@ export interface Thought {
|
||||
triagedAt?: string;
|
||||
}
|
||||
|
||||
export interface TaskListShare {
|
||||
id: string;
|
||||
taskListId: string;
|
||||
targetType: 'team' | 'user';
|
||||
targetId: string;
|
||||
targetName?: string;
|
||||
email?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TaskListMember {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface TaskList {
|
||||
id: string;
|
||||
userId: string;
|
||||
spaceId?: string;
|
||||
spaceName?: string;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
ownerName?: string;
|
||||
members?: SpaceMember[];
|
||||
isOwner?: boolean;
|
||||
members?: TaskListMember[];
|
||||
shares?: TaskListShare[];
|
||||
title: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
|
||||
+79
-5
@@ -89,11 +89,14 @@ func (s *Server) Router() http.Handler {
|
||||
r.Patch("/api/v1/triage/{id}", s.handleTriageAction)
|
||||
r.Get("/api/v1/tasks", s.handleGetTasks)
|
||||
|
||||
// Task Lists
|
||||
// Task Lists & Shares
|
||||
r.Get("/api/v1/task-lists", s.handleGetTaskLists)
|
||||
r.Post("/api/v1/task-lists", s.handleCreateTaskList)
|
||||
r.Put("/api/v1/task-lists/{id}", s.handleUpdateTaskList)
|
||||
r.Delete("/api/v1/task-lists/{id}", s.handleDeleteTaskList)
|
||||
r.Get("/api/v1/task-lists/{id}/shares", s.handleGetTaskListShares)
|
||||
r.Post("/api/v1/task-lists/{id}/shares", s.handleCreateTaskListShare)
|
||||
r.Delete("/api/v1/task-lists/{id}/shares/{shareId}", s.handleDeleteTaskListShare)
|
||||
|
||||
// Workspace Documents & Folders
|
||||
r.Get("/api/v1/folders", s.handleGetFolders)
|
||||
@@ -698,7 +701,7 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastTaskEvent(updated.UserID, "TASK_UPDATED", updated)
|
||||
s.wsHub.BroadcastToAll("TASK_UPDATED", updated)
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
@@ -709,7 +712,7 @@ func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastTaskEvent(userID, "TASK_DELETED", map[string]string{"id": thoughtID})
|
||||
s.wsHub.BroadcastToAll("TASK_DELETED", map[string]string{"id": thoughtID})
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -1566,6 +1569,7 @@ func (s *Server) handleCreateTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
SpaceID string `json:"spaceId"`
|
||||
TeamID string `json:"teamId"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
@@ -1574,7 +1578,7 @@ func (s *Server) handleCreateTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
tl, err := s.store.CreateTaskList(userID, req.SpaceID, req.Title, req.Icon, req.Color)
|
||||
tl, err := s.store.CreateTaskList(userID, req.SpaceID, req.TeamID, req.Title, req.Icon, req.Color)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -1589,17 +1593,19 @@ func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
Title *string `json:"title"`
|
||||
Icon *string `json:"icon"`
|
||||
Color *string `json:"color"`
|
||||
TeamID *string `json:"teamId"`
|
||||
SpaceID *string `json:"spaceId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.SpaceID)
|
||||
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", tl)
|
||||
writeJSON(w, http.StatusOK, tl)
|
||||
}
|
||||
|
||||
@@ -1610,6 +1616,74 @@ func (s *Server) handleDeleteTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastToAll("TASK_LIST_DELETED", map[string]string{"id": listID})
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTaskListShares(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
if _, err := s.store.GetTaskListByID(userID, listID); err != nil {
|
||||
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden oder kein Zugriff")
|
||||
return
|
||||
}
|
||||
shares, err := s.store.GetTaskListShares(listID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, shares)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
tl, err := s.store.GetTaskListByID(userID, listID)
|
||||
if err != nil || tl == nil {
|
||||
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden")
|
||||
return
|
||||
}
|
||||
if tl.UserID != userID {
|
||||
writeError(w, http.StatusForbidden, "Nur der Eigentümer kann die Liste freigeben")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
TargetType string `json:"targetType"` // team | user
|
||||
TargetID string `json:"targetId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
share, err := s.store.AddTaskListShare(listID, req.TargetType, req.TargetID, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
updatedList, _ := s.store.GetTaskListByID(userID, listID)
|
||||
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", updatedList)
|
||||
writeJSON(w, http.StatusCreated, share)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteTaskListShare(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
shareID := chi.URLParam(r, "shareId")
|
||||
tl, err := s.store.GetTaskListByID(userID, listID)
|
||||
if err != nil || tl == nil {
|
||||
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden")
|
||||
return
|
||||
}
|
||||
if tl.UserID != userID {
|
||||
writeError(w, http.StatusForbidden, "Nur der Eigentümer kann Freigaben entfernen")
|
||||
return
|
||||
}
|
||||
if err := s.store.RemoveTaskListShare(listID, shareID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
updatedList, _ := s.store.GetTaskListByID(userID, listID)
|
||||
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", updatedList)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
|
||||
@@ -158,13 +158,34 @@ type Thought struct {
|
||||
TriagedAt string `json:"triagedAt,omitempty"`
|
||||
}
|
||||
|
||||
type TaskListShare struct {
|
||||
ID string `json:"id"`
|
||||
TaskListID string `json:"taskListId"`
|
||||
TargetType string `json:"targetType"` // team | user
|
||||
TargetID string `json:"targetId"`
|
||||
TargetName string `json:"targetName,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type TaskListMember struct {
|
||||
UserID string `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"` // owner | member
|
||||
}
|
||||
|
||||
type TaskList struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
SpaceID string `json:"spaceId,omitempty"`
|
||||
SpaceName string `json:"spaceName,omitempty"`
|
||||
TeamID string `json:"teamId,omitempty"`
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
Members []SpaceMember `json:"members,omitempty"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Members []TaskListMember `json:"members,omitempty"`
|
||||
Shares []TaskListShare `json:"shares,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
|
||||
+236
-36
@@ -125,6 +125,7 @@ func (s *Store) migrate() error {
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
space_id TEXT DEFAULT '',
|
||||
team_id TEXT DEFAULT '',
|
||||
title TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '📋',
|
||||
color TEXT NOT NULL DEFAULT '#2563eb',
|
||||
@@ -321,6 +322,11 @@ func (s *Store) migrate() error {
|
||||
"CREATE TABLE IF NOT EXISTS doc_shares (id TEXT PRIMARY KEY, doc_id TEXT NOT NULL, user_id TEXT NOT NULL, permission TEXT NOT NULL CHECK(permission IN ('read', 'write')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(doc_id, user_id));",
|
||||
"CREATE INDEX IF NOT EXISTS idx_doc_shares_user ON doc_shares(user_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_doc_shares_doc ON doc_shares(doc_id);",
|
||||
|
||||
"ALTER TABLE task_lists ADD COLUMN team_id TEXT NOT NULL DEFAULT '';",
|
||||
"CREATE TABLE IF NOT EXISTS task_list_shares (id TEXT PRIMARY KEY, task_list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(task_list_id, target_type, target_id));",
|
||||
"CREATE INDEX IF NOT EXISTS idx_task_list_shares_target ON task_list_shares(target_type, target_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_task_list_shares_list ON task_list_shares(task_list_id);",
|
||||
}
|
||||
for _, q := range columnMigrations {
|
||||
_, _ = s.db.Exec(q)
|
||||
@@ -823,11 +829,23 @@ func scanThought(rows interface {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *Store) accessibleTaskListIDsQuery() string {
|
||||
return `SELECT id FROM task_lists
|
||||
WHERE user_id = ?
|
||||
OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
OR id IN (
|
||||
SELECT task_list_id FROM task_list_shares
|
||||
WHERE (target_type = 'user' AND target_id = ?)
|
||||
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))`
|
||||
}
|
||||
|
||||
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||||
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))`,
|
||||
thoughtID, userID, userID,
|
||||
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
|
||||
thoughtID, userID, userID, userID, userID, userID, userID,
|
||||
)
|
||||
return scanThought(row)
|
||||
}
|
||||
@@ -876,20 +894,20 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
||||
rows, err = s.db.Query(
|
||||
`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 (user_id = ? OR task_list_id IN (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))
|
||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||
AND task_list_id = ?
|
||||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||||
ORDER BY is_completed ASC, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`,
|
||||
userID, userID, taskListID,
|
||||
userID, userID, userID, userID, userID, userID, taskListID,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
`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 (user_id = ? OR task_list_id IN (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))
|
||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||||
ORDER BY is_completed ASC, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`,
|
||||
userID, userID,
|
||||
userID, userID, userID, userID, userID, userID,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -1060,8 +1078,8 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(
|
||||
`UPDATE thoughts SET content = ?, tags = ?, is_completed = ?, due_date = ?, priority = ?, task_list_id = ?, kanban_status = ?, notes = ?, subtasks = ? WHERE id = ? AND user_id = ?`,
|
||||
thought.Content, string(tagsJSON), completedInt, thought.DueDate, thought.Priority, thought.TaskListID, thought.KanbanStatus, thought.Notes, string(subtasksJSON), thoughtID, userID,
|
||||
`UPDATE thoughts SET content = ?, tags = ?, is_completed = ?, due_date = ?, priority = ?, task_list_id = ?, kanban_status = ?, notes = ?, subtasks = ? WHERE id = ?`,
|
||||
thought.Content, string(tagsJSON), completedInt, thought.DueDate, thought.Priority, thought.TaskListID, thought.KanbanStatus, thought.Notes, string(subtasksJSON), thoughtID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1070,7 +1088,11 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
|
||||
}
|
||||
|
||||
func (s *Store) DeleteThought(userID, thoughtID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM thoughts WHERE id = ? AND user_id = ?`, thoughtID, userID)
|
||||
thought, err := s.GetThoughtByID(userID, thoughtID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`DELETE FROM thoughts WHERE id = ?`, thought.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2529,7 +2551,7 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
|
||||
|
||||
// --- TASK LISTS ---
|
||||
|
||||
func (s *Store) CreateTaskList(userID, spaceID, title, icon, color string) (*domain.TaskList, error) {
|
||||
func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color string) (*domain.TaskList, error) {
|
||||
if strings.TrimSpace(title) == "" {
|
||||
title = "Meine Aufgaben"
|
||||
}
|
||||
@@ -2544,29 +2566,181 @@ func (s *Store) CreateTaskList(userID, spaceID, title, icon, color string) (*dom
|
||||
ID: domain.NewID("tkl"),
|
||||
UserID: userID,
|
||||
SpaceID: spaceID,
|
||||
TeamID: teamID,
|
||||
Title: strings.TrimSpace(title),
|
||||
Icon: icon,
|
||||
Color: color,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
IsOwner: true,
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO task_lists (id, user_id, space_id, title, icon, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
tl.ID, tl.UserID, tl.SpaceID, tl.Title, tl.Icon, tl.Color, tl.CreatedAt, tl.UpdatedAt,
|
||||
`INSERT INTO task_lists (id, user_id, space_id, team_id, title, icon, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
tl.ID, tl.UserID, tl.SpaceID, tl.TeamID, tl.Title, tl.Icon, tl.Color, tl.CreatedAt, tl.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tl, nil
|
||||
if teamID != "" {
|
||||
_, _ = s.AddTaskListShare(tl.ID, "team", teamID, userID)
|
||||
}
|
||||
return s.GetTaskListByID(userID, tl.ID)
|
||||
}
|
||||
|
||||
func (s *Store) AddTaskListShare(taskListID, targetType, targetID, createdBy string) (*domain.TaskListShare, error) {
|
||||
if targetType != "team" && targetType != "user" {
|
||||
return nil, errors.New("ungültiger Freigabetyp")
|
||||
}
|
||||
targetID = strings.TrimSpace(targetID)
|
||||
if targetID == "" {
|
||||
return nil, errors.New("Ziel-ID erforderlich")
|
||||
}
|
||||
now := domain.NowISO()
|
||||
share := &domain.TaskListShare{
|
||||
ID: domain.NewID("tls"),
|
||||
TaskListID: taskListID,
|
||||
TargetType: targetType,
|
||||
TargetID: targetID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO task_list_shares (id, task_list_id, target_type, target_id, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(task_list_id, target_type, target_id) DO NOTHING`,
|
||||
share.ID, share.TaskListID, share.TargetType, share.TargetID, share.CreatedAt, createdBy,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if targetType == "team" {
|
||||
_, _ = s.db.Exec(`UPDATE task_lists SET team_id = ? WHERE id = ? AND (team_id = '' OR team_id IS NULL)`, targetID, taskListID)
|
||||
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName)
|
||||
} else {
|
||||
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, targetID).Scan(&share.TargetName, &share.Email)
|
||||
}
|
||||
return share, nil
|
||||
}
|
||||
|
||||
func (s *Store) RemoveTaskListShare(taskListID, shareIDOrTargetID string) error {
|
||||
var targetType, targetID string
|
||||
_ = s.db.QueryRow(`SELECT target_type, target_id FROM task_list_shares WHERE task_list_id = ? AND (id = ? OR target_id = ?)`, taskListID, shareIDOrTargetID, shareIDOrTargetID).Scan(&targetType, &targetID)
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`DELETE FROM task_list_shares WHERE task_list_id = ? AND (id = ? OR target_id = ?)`,
|
||||
taskListID, shareIDOrTargetID, shareIDOrTargetID,
|
||||
)
|
||||
if targetType == "team" {
|
||||
_, _ = s.db.Exec(`UPDATE task_lists SET team_id = '' WHERE id = ? AND team_id = ?`, taskListID, targetID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetTaskListShares(taskListID string) ([]domain.TaskListShare, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, task_list_id, target_type, target_id, created_at
|
||||
FROM task_list_shares WHERE task_list_id = ? ORDER BY created_at ASC`,
|
||||
taskListID,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.TaskListShare{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
shares := make([]domain.TaskListShare, 0)
|
||||
for rows.Next() {
|
||||
var sh domain.TaskListShare
|
||||
if err := rows.Scan(&sh.ID, &sh.TaskListID, &sh.TargetType, &sh.TargetID, &sh.CreatedAt); err == nil {
|
||||
shares = append(shares, sh)
|
||||
}
|
||||
}
|
||||
for i := range shares {
|
||||
if shares[i].TargetType == "team" {
|
||||
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName)
|
||||
} else {
|
||||
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName, &shares[i].Email)
|
||||
}
|
||||
}
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMember {
|
||||
memberMap := make(map[string]domain.TaskListMember)
|
||||
|
||||
// 1. Owner
|
||||
var ownerName, ownerEmail string
|
||||
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, tl.UserID).Scan(&ownerName, &ownerEmail)
|
||||
memberMap[tl.UserID] = domain.TaskListMember{
|
||||
UserID: tl.UserID,
|
||||
DisplayName: ownerName,
|
||||
Email: ownerEmail,
|
||||
Role: "owner",
|
||||
}
|
||||
|
||||
// 2. Team members if tl.TeamID != ""
|
||||
if tl.TeamID != "" {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT u.id, u.display_name, u.email FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`,
|
||||
tl.TeamID,
|
||||
)
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var m domain.TaskListMember
|
||||
if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email); err == nil {
|
||||
m.Role = "member"
|
||||
memberMap[m.UserID] = m
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// 3. From task_list_shares
|
||||
for _, sh := range tl.Shares {
|
||||
if sh.TargetType == "team" {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT u.id, u.display_name, u.email FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`,
|
||||
sh.TargetID,
|
||||
)
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var m domain.TaskListMember
|
||||
if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email); err == nil {
|
||||
m.Role = "member"
|
||||
memberMap[m.UserID] = m
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
}
|
||||
} else if sh.TargetType == "user" {
|
||||
var m domain.TaskListMember
|
||||
if err := s.db.QueryRow(`SELECT id, display_name, email FROM users WHERE id = ?`, sh.TargetID).Scan(&m.UserID, &m.DisplayName, &m.Email); err == nil {
|
||||
m.Role = "member"
|
||||
memberMap[m.UserID] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]domain.TaskListMember, 0, len(memberMap))
|
||||
for _, m := range memberMap {
|
||||
result = append(result, m)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, space_id, title, icon, color, created_at, updated_at
|
||||
`SELECT id, user_id, space_id, COALESCE(team_id, ''), title, icon, color, created_at, updated_at
|
||||
FROM task_lists
|
||||
WHERE user_id = ? OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))
|
||||
WHERE user_id = ?
|
||||
OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
OR id IN (
|
||||
SELECT task_list_id FROM task_list_shares
|
||||
WHERE (target_type = 'user' AND target_id = ?)
|
||||
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))
|
||||
ORDER BY updated_at DESC`,
|
||||
userID, userID,
|
||||
userID, userID, userID, userID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.TaskList{}, err
|
||||
@@ -2575,25 +2749,30 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
|
||||
list := make([]domain.TaskList, 0)
|
||||
for rows.Next() {
|
||||
var tl domain.TaskList
|
||||
if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt); err == nil {
|
||||
if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt); err == nil {
|
||||
tl.IsOwner = (tl.UserID == userID)
|
||||
list = append(list, tl)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for i := range list {
|
||||
list[i].Members = make([]domain.SpaceMember, 0)
|
||||
list[i].Members = make([]domain.TaskListMember, 0)
|
||||
list[i].Shares = make([]domain.TaskListShare, 0)
|
||||
|
||||
_ = s.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`,
|
||||
list[i].ID,
|
||||
).Scan(&list[i].TasksCount)
|
||||
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, list[i].UserID).Scan(&list[i].OwnerName)
|
||||
if list[i].SpaceID != "" {
|
||||
_ = s.db.QueryRow(`SELECT name FROM spaces WHERE id = ?`, list[i].SpaceID).Scan(&list[i].SpaceName)
|
||||
if members, err := s.GetSpaceMembers(list[i].SpaceID); err == nil && members != nil {
|
||||
list[i].Members = members
|
||||
}
|
||||
|
||||
if list[i].TeamID != "" {
|
||||
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, list[i].TeamID).Scan(&list[i].TeamName)
|
||||
}
|
||||
|
||||
shares, _ := s.GetTaskListShares(list[i].ID)
|
||||
list[i].Shares = shares
|
||||
list[i].Members = s.getMembersForTaskList(&list[i])
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
@@ -2601,26 +2780,36 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
|
||||
func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error) {
|
||||
var tl domain.TaskList
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, user_id, space_id, title, icon, color, created_at, updated_at
|
||||
FROM task_lists WHERE id = ? AND (user_id = ? OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))`,
|
||||
listID, userID, userID,
|
||||
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt)
|
||||
`SELECT id, user_id, space_id, COALESCE(team_id, ''), title, icon, color, created_at, updated_at
|
||||
FROM task_lists
|
||||
WHERE id = ? AND (
|
||||
user_id = ?
|
||||
OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
OR id IN (
|
||||
SELECT task_list_id FROM task_list_shares
|
||||
WHERE (target_type = 'user' AND target_id = ?)
|
||||
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))
|
||||
)`,
|
||||
listID, userID, userID, userID, userID, userID,
|
||||
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tl.Members = make([]domain.SpaceMember, 0)
|
||||
tl.IsOwner = (tl.UserID == userID)
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`, tl.ID).Scan(&tl.TasksCount)
|
||||
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, tl.UserID).Scan(&tl.OwnerName)
|
||||
if tl.SpaceID != "" {
|
||||
_ = s.db.QueryRow(`SELECT name FROM spaces WHERE id = ?`, tl.SpaceID).Scan(&tl.SpaceName)
|
||||
if members, err := s.GetSpaceMembers(tl.SpaceID); err == nil && members != nil {
|
||||
tl.Members = members
|
||||
}
|
||||
if tl.TeamID != "" {
|
||||
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, tl.TeamID).Scan(&tl.TeamName)
|
||||
}
|
||||
shares, _ := s.GetTaskListShares(tl.ID)
|
||||
tl.Shares = shares
|
||||
tl.Members = s.getMembersForTaskList(&tl)
|
||||
return &tl, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, spaceID *string) (*domain.TaskList, error) {
|
||||
func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID, spaceID *string) (*domain.TaskList, error) {
|
||||
tl, err := s.GetTaskListByID(userID, listID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -2637,10 +2826,20 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, spaceI
|
||||
if spaceID != nil {
|
||||
tl.SpaceID = *spaceID
|
||||
}
|
||||
if teamID != nil {
|
||||
oldTeamID := tl.TeamID
|
||||
tl.TeamID = *teamID
|
||||
if oldTeamID != "" && oldTeamID != *teamID {
|
||||
_ = s.RemoveTaskListShare(listID, oldTeamID)
|
||||
}
|
||||
if *teamID != "" {
|
||||
_, _ = s.AddTaskListShare(listID, "team", *teamID, userID)
|
||||
}
|
||||
}
|
||||
tl.UpdatedAt = domain.NowISO()
|
||||
_, err = s.db.Exec(
|
||||
`UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, updated_at = ? WHERE id = ? AND user_id = ?`,
|
||||
tl.Title, tl.Icon, tl.Color, tl.SpaceID, tl.UpdatedAt, listID, userID,
|
||||
`UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, team_id = ?, updated_at = ? WHERE id = ?`,
|
||||
tl.Title, tl.Icon, tl.Color, tl.SpaceID, tl.TeamID, tl.UpdatedAt, listID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -2650,6 +2849,7 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, spaceI
|
||||
|
||||
func (s *Store) DeleteTaskList(userID, listID string) error {
|
||||
_, _ = s.db.Exec(`UPDATE thoughts SET task_list_id = '' WHERE task_list_id = ?`, listID)
|
||||
_, _ = s.db.Exec(`DELETE FROM task_list_shares WHERE task_list_id = ?`, listID)
|
||||
_, err := s.db.Exec(`DELETE FROM task_lists WHERE id = ? AND user_id = ?`, listID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// 5. Test TaskLists CRUD
|
||||
taskList, err := store.CreateTaskList(user.ID, sp.ID, "Haus & Garten", "🏡", "#10b981")
|
||||
taskList, err := store.CreateTaskList(user.ID, sp.ID, "", "Haus & Garten", "🏡", "#10b981")
|
||||
if err != nil || taskList.ID == "" {
|
||||
t.Fatalf("CreateTaskList failed: %v", err)
|
||||
}
|
||||
@@ -122,6 +122,20 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
t.Fatalf("AddTeamMember failed: %v", err)
|
||||
}
|
||||
|
||||
// Test TaskList Sharing with Team
|
||||
shareTeam, err := store.AddTaskListShare(taskList.ID, "team", team.ID, user.ID)
|
||||
if err != nil || shareTeam.ID == "" {
|
||||
t.Fatalf("AddTaskListShare team failed: %v", err)
|
||||
}
|
||||
tlShares, err := store.GetTaskListShares(taskList.ID)
|
||||
if err != nil || len(tlShares) == 0 {
|
||||
t.Fatalf("GetTaskListShares failed: %v", err)
|
||||
}
|
||||
fetchedTL, err := store.GetTaskListByID(user.ID, taskList.ID)
|
||||
if err != nil || len(fetchedTL.Shares) == 0 {
|
||||
t.Fatalf("GetTaskListByID with shares failed: %v", err)
|
||||
}
|
||||
|
||||
// 7. Test System Settings
|
||||
sysSettings, err := store.GetSystemSettings()
|
||||
if err != nil || sysSettings.InstanceName == "" {
|
||||
|
||||
Reference in new issue
Block a user