fixed sharing

This commit is contained in:
2026-02-05 18:43:14 -06:00
parent 120484d54e
commit f783c20f60
4 changed files with 689 additions and 23 deletions
+355 -17
View File
@@ -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: "",