fixed sharing
This commit is contained in:
+355
-17
@@ -33,6 +33,7 @@ export interface Task {
|
||||
lastUncompleted?: string; // ISO date of last automatic reset
|
||||
};
|
||||
size?: number; // 0-10, task complexity/size
|
||||
sharedWith?: Array<{ userId: string; access: 'view' | 'edit' }>; // Users this task is shared with
|
||||
}
|
||||
|
||||
export const checkRecurringTasks = () => {
|
||||
@@ -139,6 +140,14 @@ export interface Filter {
|
||||
editedToday: boolean;
|
||||
}
|
||||
|
||||
export interface ShareRule {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
targetUserId: string;
|
||||
type: 'tag' | 'all';
|
||||
tagName?: string;
|
||||
}
|
||||
|
||||
interface TaskStore {
|
||||
tasks: Task[];
|
||||
pWeight: number;
|
||||
@@ -148,6 +157,7 @@ interface TaskStore {
|
||||
tagDefinitions: TagDefinition[];
|
||||
templates: TaskTemplate[];
|
||||
filter: Filter;
|
||||
shareRules: ShareRule[];
|
||||
}
|
||||
|
||||
// Initial empty state
|
||||
@@ -166,7 +176,8 @@ export const [store, setStore] = createStore<TaskStore>({
|
||||
urgencyMin: 1,
|
||||
urgencyMax: 10,
|
||||
editedToday: false
|
||||
}
|
||||
},
|
||||
shareRules: []
|
||||
});
|
||||
|
||||
export const matchesFilter = (task: Task) => {
|
||||
@@ -310,10 +321,45 @@ const mapRecordToTask = (r: any): Task => {
|
||||
created: r.created,
|
||||
updated: r.updated,
|
||||
recurrence: r.recurrence,
|
||||
size: (r.size === null || r.size === undefined) ? undefined : r.size
|
||||
size: (r.size === null || r.size === undefined) ? undefined : r.size,
|
||||
sharedWith: r.sharedWith
|
||||
};
|
||||
};
|
||||
|
||||
const mapRecordToShareRule = (r: any): ShareRule => {
|
||||
return {
|
||||
id: r.id,
|
||||
ownerId: r.ownerId,
|
||||
targetUserId: r.targetUserId,
|
||||
type: r.type as 'tag' | 'all',
|
||||
tagName: r.tagName
|
||||
};
|
||||
};
|
||||
|
||||
// Helper to check if a task should be visible based on ownership, shares, or rules
|
||||
const shouldTaskBeVisible = (task: any, currentUserId: string): boolean => {
|
||||
// Own task
|
||||
if (task.user === currentUserId) return true;
|
||||
|
||||
// Individually shared
|
||||
const sharedWith = task.sharedWith || [];
|
||||
if (sharedWith.some((s: any) => s.userId === currentUserId)) return true;
|
||||
|
||||
// ShareRule match
|
||||
const matchingRule = store.shareRules.find(rule => {
|
||||
if (rule.targetUserId !== currentUserId) return false;
|
||||
if (task.user !== rule.ownerId) return false;
|
||||
if (rule.type === 'all') return true;
|
||||
if (rule.type === 'tag') {
|
||||
const tags = task.tags || [];
|
||||
return tags.includes(rule.tagName);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return !!matchingRule;
|
||||
};
|
||||
|
||||
export const subscribeToRealtime = async () => {
|
||||
// Unsubscribe first to avoid duplicates
|
||||
await pb.collection(TASGRID_COLLECTION).unsubscribe('*');
|
||||
@@ -325,9 +371,6 @@ export const subscribeToRealtime = async () => {
|
||||
// Check if it's a template
|
||||
const isTemplate = e.record.tags?.includes("__template__");
|
||||
if (isTemplate) {
|
||||
// We don't auto-add templates to the main task list usually?
|
||||
// Wait, `store.templates` is separate.
|
||||
// We should handle templates too if we want sync on templates.
|
||||
let meta: any = {};
|
||||
try { meta = JSON.parse(e.record.content || "{}"); } catch (e) { }
|
||||
const newTemplate: TaskTemplate = {
|
||||
@@ -343,11 +386,17 @@ export const subscribeToRealtime = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular Task
|
||||
// Regular Task - check if already exists and if it should be visible
|
||||
const exists = store.tasks.find(t => t.id === e.record.id);
|
||||
if (!exists) {
|
||||
const newTask = mapRecordToTask(e.record);
|
||||
setStore("tasks", t => [newTask, ...t]);
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
const isOwnTask = e.record.user === currentUserId;
|
||||
|
||||
// Only add if it's own task or should be visible via sharing
|
||||
if (isOwnTask || (currentUserId && shouldTaskBeVisible(e.record, currentUserId))) {
|
||||
const newTask = mapRecordToTask(e.record);
|
||||
setStore("tasks", t => [newTask, ...t]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,23 +418,36 @@ export const subscribeToRealtime = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal Task - only apply if incoming record is newer or equal
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
const existingTask = store.tasks.find(t => t.id === e.record.id);
|
||||
const isOwnTask = e.record.user === currentUserId;
|
||||
const incomingUpdated = new Date(e.record.updated).getTime();
|
||||
|
||||
if (existingTask) {
|
||||
const incomingUpdated = new Date(e.record.updated).getTime();
|
||||
const localUpdated = new Date(existingTask.updated).getTime();
|
||||
|
||||
// Only apply update if incoming is newer (or we don't have a timestamp)
|
||||
if (!existingTask.updated || incomingUpdated >= localUpdated) {
|
||||
// For shared tasks, re-evaluate visibility (e.g., tags may have changed)
|
||||
if (!isOwnTask && currentUserId) {
|
||||
if (!shouldTaskBeVisible(e.record, currentUserId)) {
|
||||
// No longer visible, remove it
|
||||
setStore("tasks", t => t.filter(x => x.id !== e.record.id));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedTask = mapRecordToTask(e.record);
|
||||
setStore("tasks", t => t.id === e.record.id, updatedTask);
|
||||
} else {
|
||||
console.log("Skipping stale realtime update for task:", e.record.id);
|
||||
}
|
||||
} else {
|
||||
// Task doesn't exist locally, add it
|
||||
const updatedTask = mapRecordToTask(e.record);
|
||||
setStore("tasks", t => [updatedTask, ...t]);
|
||||
// Task doesn't exist locally - check if it should be visible now
|
||||
if (currentUserId && shouldTaskBeVisible(e.record, currentUserId)) {
|
||||
const newTask = mapRecordToTask(e.record);
|
||||
setStore("tasks", t => [newTask, ...t]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,6 +457,67 @@ export const subscribeToRealtime = async () => {
|
||||
setStore("templates", t => (t || []).filter(x => x.id !== e.record.id));
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to Share Rules
|
||||
await pb.collection(SHARE_RULES_COLLECTION).unsubscribe('*');
|
||||
pb.collection(SHARE_RULES_COLLECTION).subscribe('*', async (e) => {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
|
||||
if (e.action === 'create') {
|
||||
const newRule = mapRecordToShareRule(e.record);
|
||||
|
||||
// Add to shareRules store
|
||||
setStore("shareRules", (rules) => {
|
||||
if (rules.some(r => r.id === newRule.id)) return rules;
|
||||
return [...rules, newRule];
|
||||
});
|
||||
|
||||
// If I'm the target, fetch matching tasks from the owner
|
||||
if (newRule.targetUserId === currentUserId) {
|
||||
try {
|
||||
let filter = `user = "${newRule.ownerId}"`;
|
||||
if (newRule.type === 'tag' && newRule.tagName) {
|
||||
filter += ` && tags ~ "${newRule.tagName}"`;
|
||||
}
|
||||
|
||||
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter,
|
||||
sort: '-created',
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
records.forEach((r: any) => {
|
||||
if (!r.tags?.includes("__template__") && !store.tasks.find(t => t.id === r.id)) {
|
||||
const newTask = mapRecordToTask(r);
|
||||
setStore("tasks", t => [newTask, ...t]);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Failed to fetch tasks for new share rule:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.action === 'delete') {
|
||||
const deletedRuleId = e.record.id;
|
||||
const deletedRule = store.shareRules.find(r => r.id === deletedRuleId);
|
||||
|
||||
// Remove from shareRules store
|
||||
setStore("shareRules", (rules) => rules.filter(r => r.id !== deletedRuleId));
|
||||
|
||||
// If I was the target, remove tasks that are no longer visible
|
||||
if (deletedRule && deletedRule.targetUserId === currentUserId) {
|
||||
setStore("tasks", tasks =>
|
||||
tasks.filter(task => shouldTaskBeVisible(task, currentUserId!))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (e.action === 'update') {
|
||||
const updatedRule = mapRecordToShareRule(e.record);
|
||||
setStore("shareRules", (rules) => rules.map(r => r.id === updatedRule.id ? updatedRule : r));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const initStore = async () => {
|
||||
@@ -456,16 +579,44 @@ export const initStore = async () => {
|
||||
console.warn("Failed to load tags:", tagErr);
|
||||
}
|
||||
|
||||
// 2. Fetch Tasks & Templates
|
||||
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `user = "${pb.authStore.model?.id}"`,
|
||||
// 2. Fetch Tasks & Templates + Shared Tasks in PARALLEL
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
|
||||
// Prepare parallel fetch promises (requestKey: null prevents auto-cancellation)
|
||||
const ownTasksPromise = pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `user = "${currentUserId}"`,
|
||||
sort: '-created',
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
const individuallySharedPromise = currentUserId
|
||||
? pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `sharedWith ~ "${currentUserId}"`,
|
||||
sort: '-created',
|
||||
requestKey: null
|
||||
}).catch(err => { console.warn("Failed to load individually shared tasks:", err); return []; })
|
||||
: Promise.resolve([]);
|
||||
|
||||
const shareRulesPromise = currentUserId
|
||||
? pb.collection(SHARE_RULES_COLLECTION).getFullList({
|
||||
filter: `targetUserId = "${currentUserId}"`,
|
||||
requestKey: null
|
||||
}).catch(err => { console.warn("Failed to load share rules:", err); return []; })
|
||||
: Promise.resolve([]);
|
||||
|
||||
// Wait for all initial fetches
|
||||
const [ownRecords, individuallySharedRecords, incomingRules] = await Promise.all([
|
||||
ownTasksPromise,
|
||||
individuallySharedPromise,
|
||||
shareRulesPromise
|
||||
]);
|
||||
|
||||
const allTasks: Task[] = [];
|
||||
const loadedTemplates: TaskTemplate[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
records.forEach(r => {
|
||||
// Process own tasks & templates
|
||||
ownRecords.forEach(r => {
|
||||
const tags = r.tags || [];
|
||||
if (tags.includes("__template__")) {
|
||||
let meta: any = {};
|
||||
@@ -481,15 +632,72 @@ export const initStore = async () => {
|
||||
});
|
||||
} else {
|
||||
allTasks.push(mapRecordToTask(r));
|
||||
seenIds.add(r.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Process individually shared tasks
|
||||
individuallySharedRecords.forEach((r: any) => {
|
||||
if (!seenIds.has(r.id)) {
|
||||
const tags = r.tags || [];
|
||||
if (!tags.includes("__template__")) {
|
||||
allTasks.push(mapRecordToTask(r));
|
||||
seenIds.add(r.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Process ShareRules - fetch tasks from each owner in parallel
|
||||
if (incomingRules.length > 0) {
|
||||
// Group rules by owner
|
||||
const ownerRules = new Map<string, { type: 'all' | 'tag', tagName?: string }[]>();
|
||||
incomingRules.forEach((rule: any) => {
|
||||
const existing = ownerRules.get(rule.ownerId) || [];
|
||||
existing.push({ type: rule.type, tagName: rule.tagName });
|
||||
ownerRules.set(rule.ownerId, existing);
|
||||
});
|
||||
|
||||
// Fetch from all owners in parallel
|
||||
const ownerFetchPromises = Array.from(ownerRules.entries()).map(async ([ownerId, rules]) => {
|
||||
const hasAllRule = rules.some(r => r.type === 'all');
|
||||
const tagRules = rules.filter(r => r.type === 'tag').map(r => r.tagName).filter(Boolean);
|
||||
|
||||
let filter = `user = "${ownerId}"`;
|
||||
if (!hasAllRule && tagRules.length > 0) {
|
||||
const tagFilters = tagRules.map(t => `tags ~ "${t}"`).join(' || ');
|
||||
filter += ` && (${tagFilters})`;
|
||||
}
|
||||
|
||||
try {
|
||||
return await pb.collection(TASGRID_COLLECTION).getFullList({ filter, sort: '-created', requestKey: null });
|
||||
} catch (err) {
|
||||
console.warn(`Failed to fetch tasks from owner ${ownerId}:`, err);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const ownerResults = await Promise.all(ownerFetchPromises);
|
||||
ownerResults.flat().forEach((r: any) => {
|
||||
if (!seenIds.has(r.id)) {
|
||||
const tags = r.tags || [];
|
||||
if (!tags.includes("__template__")) {
|
||||
allTasks.push(mapRecordToTask(r));
|
||||
seenIds.add(r.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set store ONCE with all tasks
|
||||
setStore("tasks", reconcile(allTasks));
|
||||
setStore("templates", loadedTemplates);
|
||||
|
||||
// Check recurring tasks after load
|
||||
checkRecurringTasks();
|
||||
|
||||
// 2.7. Fetch Share Rules (owned by current user)
|
||||
await loadShareRules();
|
||||
|
||||
// 3. Subscribe to Realtime Updates
|
||||
await subscribeToRealtime();
|
||||
|
||||
@@ -931,6 +1139,136 @@ export const setFilter = (update: Partial<Filter>) => {
|
||||
setStore("filter", (f) => ({ ...f, ...update }));
|
||||
};
|
||||
|
||||
// -- Sharing Functions --
|
||||
|
||||
export const shareTask = async (taskId: string, userId: string, access: 'view' | 'edit' = 'view') => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
const task = store.tasks.find(t => t.id === taskId);
|
||||
if (!task) return;
|
||||
|
||||
const existingShares = task.sharedWith || [];
|
||||
if (existingShares.some(s => s.userId === userId)) {
|
||||
toast.info(`Task is already shared with this user`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newShares = [...existingShares, { userId, access }];
|
||||
|
||||
// Update task with new sharedWith
|
||||
await updateTask(taskId, { sharedWith: newShares });
|
||||
toast.success(`Task shared successfully`);
|
||||
};
|
||||
|
||||
export const revokeShare = async (taskId: string, userId: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
const task = store.tasks.find(t => t.id === taskId);
|
||||
if (!task) return;
|
||||
|
||||
const existingShares = task.sharedWith || [];
|
||||
const newShares = existingShares.filter(s => s.userId !== userId);
|
||||
|
||||
await updateTask(taskId, { sharedWith: newShares });
|
||||
toast.success(`Share revoked`);
|
||||
};
|
||||
|
||||
export const splitTask = async (taskId: string, userId: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
const task = store.tasks.find(t => t.id === taskId);
|
||||
if (!task) return;
|
||||
|
||||
try {
|
||||
// Create a duplicate task owned by the target user
|
||||
const duplicateData = {
|
||||
user: userId,
|
||||
title: task.title,
|
||||
content: task.content,
|
||||
priority: task.priority,
|
||||
size: task.size,
|
||||
tags: task.tags,
|
||||
startDate: task.startDate,
|
||||
dueDate: task.dueDate,
|
||||
completed: task.completed,
|
||||
deletedAt: null,
|
||||
sharedWith: [], // Start fresh with no shares
|
||||
recurrence: task.recurrence
|
||||
};
|
||||
|
||||
await pb.collection(TASGRID_COLLECTION).create(duplicateData, { requestKey: null });
|
||||
|
||||
// Remove the user from the original task's sharedWith
|
||||
const existingShares = task.sharedWith || [];
|
||||
const newShares = existingShares.filter(s => s.userId !== userId);
|
||||
await updateTask(taskId, { sharedWith: newShares });
|
||||
|
||||
toast.success(`Task split - user now has their own copy`);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to split task:", err);
|
||||
console.error("Error details:", err.response?.data || err.data || err.message);
|
||||
toast.error("Failed to split task: " + (err.response?.data?.message || err.message || "Unknown error"));
|
||||
}
|
||||
};
|
||||
|
||||
// -- Share Rules (for tag-based and all-tasks sharing) --
|
||||
|
||||
const SHARE_RULES_COLLECTION = 'TasGrid_ShareRules';
|
||||
|
||||
export const loadShareRules = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
try {
|
||||
const records = await pb.collection(SHARE_RULES_COLLECTION).getFullList({
|
||||
filter: `ownerId = "${pb.authStore.model?.id}"`
|
||||
});
|
||||
|
||||
const rules: ShareRule[] = records.map(r => ({
|
||||
id: r.id,
|
||||
ownerId: r.ownerId,
|
||||
targetUserId: r.targetUserId,
|
||||
type: r.type as 'tag' | 'all',
|
||||
tagName: r.tagName
|
||||
}));
|
||||
|
||||
setStore("shareRules", rules);
|
||||
} catch (err) {
|
||||
console.error("Failed to load share rules:", err);
|
||||
// Collection might not exist yet, that's okay
|
||||
}
|
||||
};
|
||||
|
||||
export const addShareRule = async (type: 'tag' | 'all', targetUserId: string, tagName?: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
try {
|
||||
await pb.collection(SHARE_RULES_COLLECTION).create({
|
||||
ownerId: pb.authStore.model?.id,
|
||||
targetUserId,
|
||||
type,
|
||||
tagName: type === 'tag' ? tagName : null
|
||||
});
|
||||
// Realtime subscription will handle adding to store
|
||||
toast.success(`Share rule created`);
|
||||
} catch (err) {
|
||||
console.error("Failed to create share rule:", err);
|
||||
toast.error("Failed to create share rule. Make sure the collection exists in PocketBase.");
|
||||
}
|
||||
};
|
||||
|
||||
export const removeShareRule = async (ruleId: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
try {
|
||||
await pb.collection(SHARE_RULES_COLLECTION).delete(ruleId);
|
||||
setStore("shareRules", (rules) => rules.filter(r => r.id !== ruleId));
|
||||
toast.success("Share rule removed");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete share rule:", err);
|
||||
toast.error("Failed to delete share rule.");
|
||||
}
|
||||
};
|
||||
|
||||
export const clearFilter = () => {
|
||||
setStore("filter", {
|
||||
query: "",
|
||||
|
||||
Reference in New Issue
Block a user