Compare commits
2 Commits
a1ebb8f898
...
1618b3bf92
| Author | SHA1 | Date | |
|---|---|---|---|
| 1618b3bf92 | |||
| ce7398b660 |
+204
-30
@@ -221,12 +221,27 @@ export const matchesFilter = (task: Task) => {
|
|||||||
const isExplicitlyShared = task.sharedWith?.some(s => s.userId === currentUserId);
|
const isExplicitlyShared = task.sharedWith?.some(s => s.userId === currentUserId);
|
||||||
|
|
||||||
// Tag-based share rules targeting me
|
// Tag-based share rules targeting me
|
||||||
const isTagRuleShared = store.shareRules.some(r =>
|
const isTagRuleShared = store.shareRules.some(r => {
|
||||||
r.targetUserId === currentUserId &&
|
if (r.targetUserId !== currentUserId) return false;
|
||||||
r.ownerId === task.ownerId &&
|
|
||||||
r.type === 'tag' &&
|
// Global Rule / Self-Rule: Match purely on tag, ignore owner check
|
||||||
task.tags?.includes(r.tagName || "")
|
// logic: If I have a rule for tag "T" assigned to myself, show ANY task with tag "T"
|
||||||
);
|
if (r.ownerId === r.targetUserId && r.type === 'tag') {
|
||||||
|
const tagName = r.tagName || "";
|
||||||
|
|
||||||
|
// EXCLUDE Buckets from the main "Mine" view
|
||||||
|
// The user wants to see "Direct Shares" (User Tags), not "Bucket Subscriptions" here.
|
||||||
|
const isBucket = store.buckets.some(b => b.name.toLowerCase() === tagName.toLowerCase());
|
||||||
|
if (isBucket) return false;
|
||||||
|
|
||||||
|
return task.tags?.some(t => t.toLowerCase() === tagName.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard Rule: Match owner AND tag
|
||||||
|
return r.ownerId === task.ownerId &&
|
||||||
|
r.type === 'tag' &&
|
||||||
|
task.tags?.includes(r.tagName || "");
|
||||||
|
});
|
||||||
|
|
||||||
// Bucket visibility logic for "My Bucket":
|
// Bucket visibility logic for "My Bucket":
|
||||||
// Users requested: "Any user who adds that bucket to their workspaces list can view the workspace and see tasks in that bucket from all users."
|
// Users requested: "Any user who adds that bucket to their workspaces list can view the workspace and see tasks in that bucket from all users."
|
||||||
@@ -435,6 +450,16 @@ const shouldTaskBeVisible = (task: any, currentUserId: string): boolean => {
|
|||||||
// ShareRule match
|
// ShareRule match
|
||||||
const matchingRule = store.shareRules.find(rule => {
|
const matchingRule = store.shareRules.find(rule => {
|
||||||
if (rule.targetUserId !== currentUserId) return false;
|
if (rule.targetUserId !== currentUserId) return false;
|
||||||
|
|
||||||
|
// Global Rule Logic: If owner == target, it's a "Global Subscription" to that tag.
|
||||||
|
// We ignore the rule.ownerId check (which would match the task.user)
|
||||||
|
// and instead match purely on the tag.
|
||||||
|
if (rule.ownerId === rule.targetUserId && rule.type === 'tag') {
|
||||||
|
const tags = task.tags || [];
|
||||||
|
return tags.some(t => t.toLowerCase() === (rule.tagName || "").toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard Rule Logic: Match specific owner
|
||||||
if (task.user !== rule.ownerId) return false;
|
if (task.user !== rule.ownerId) return false;
|
||||||
if (rule.type === 'all') return true;
|
if (rule.type === 'all') return true;
|
||||||
if (rule.type === 'tag') {
|
if (rule.type === 'tag') {
|
||||||
@@ -768,6 +793,9 @@ export const initStore = async () => {
|
|||||||
.map(id => store.buckets.find(b => b.id === id)?.name)
|
.map(id => store.buckets.find(b => b.id === id)?.name)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// Sync System Tags & Share Rules FIRST to ensure Self-Rules exist for query construction
|
||||||
|
await syncSystemTagsAndRules();
|
||||||
|
|
||||||
// Add rules from shareRules store (which was just synced)
|
// Add rules from shareRules store (which was just synced)
|
||||||
const incomingRules = store.shareRules.filter(r => r.targetUserId === currentUserId);
|
const incomingRules = store.shareRules.filter(r => r.targetUserId === currentUserId);
|
||||||
|
|
||||||
@@ -799,18 +827,49 @@ export const initStore = async () => {
|
|||||||
|
|
||||||
// Share Rules Incomplete
|
// Share Rules Incomplete
|
||||||
if (incomingRules.length > 0) {
|
if (incomingRules.length > 0) {
|
||||||
const ownerRules = new Map<string, { type: 'all' | 'tag', tagName?: string }[]>();
|
// Separate "Global Rules" (Self-Rules) from "Specific User Rules"
|
||||||
|
const globalTagRules: string[] = [];
|
||||||
|
const specificUserRules = new Map<string, { type: 'all' | 'tag', tagName?: string }[]>();
|
||||||
|
|
||||||
incomingRules.forEach((rule: any) => {
|
incomingRules.forEach((rule: any) => {
|
||||||
const existing = ownerRules.get(rule.ownerId) || [];
|
if (rule.ownerId === rule.targetUserId && rule.type === 'tag' && rule.tagName) {
|
||||||
existing.push({ type: rule.type, tagName: rule.tagName });
|
globalTagRules.push(rule.tagName);
|
||||||
ownerRules.set(rule.ownerId, existing);
|
} else {
|
||||||
|
const existing = specificUserRules.get(rule.ownerId) || [];
|
||||||
|
existing.push({ type: rule.type, tagName: rule.tagName });
|
||||||
|
specificUserRules.set(rule.ownerId, existing);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Array.from(ownerRules.entries()).forEach(([ownerId, rules]) => {
|
// 1. Fetch Global Rule Tasks (from ANY user)
|
||||||
|
// 1. Fetch Global Rule Tasks (from ANY user)
|
||||||
|
if (globalTagRules.length > 0) {
|
||||||
|
// We want tasks that have ANY of these tags, AND are not completed
|
||||||
|
// filter: (tags ~ "A" || tags ~ "B") && completed = false
|
||||||
|
const tagFilter = globalTagRules.map(t => `tags ~ "${t}"`).join(' || ');
|
||||||
|
incompletePromises.push(pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
|
filter: `(${tagFilter}) && completed = false`,
|
||||||
|
sort: '-created',
|
||||||
|
fields: LIST_FIELDS,
|
||||||
|
requestKey: null
|
||||||
|
}).catch(() => []));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch Specific User Rule Tasks (from specific owner)
|
||||||
|
Array.from(specificUserRules.entries()).forEach(([ownerId, rules]) => {
|
||||||
const hasAllRule = rules.some(r => r.type === 'all');
|
const hasAllRule = rules.some(r => r.type === 'all');
|
||||||
const tagRules = rules.filter(r => r.type === 'tag').map(r => r.tagName).filter(Boolean);
|
const tagRules = rules.filter(r => r.type === 'tag').map(r => r.tagName).filter(Boolean);
|
||||||
|
|
||||||
let filter = `user = "${ownerId}" && completed = false`;
|
let filter = `user = "${ownerId}" && completed = false`;
|
||||||
if (!hasAllRule && tagRules.length > 0) filter += ` && (${tagRules.map(t => `tags ~ "${t}"`).join(' || ')})`;
|
|
||||||
|
if (hasAllRule) {
|
||||||
|
// Get everything from this user
|
||||||
|
} else if (tagRules.length > 0) {
|
||||||
|
// Get specific tags from this user
|
||||||
|
filter += ` && (${tagRules.map(t => `tags ~ "${t}"`).join(' || ')})`;
|
||||||
|
} else {
|
||||||
|
return; // No valid rules for this user
|
||||||
|
}
|
||||||
|
|
||||||
incompletePromises.push(pb.collection(TASGRID_COLLECTION).getFullList({
|
incompletePromises.push(pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
filter,
|
filter,
|
||||||
@@ -903,8 +962,8 @@ export const initStore = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync System Tags & Share Rules (Heavy maintenance task)
|
// Sync System Tags & Share Rules (Moved to top of backgroundSync to ensure rules exist before use)
|
||||||
await syncSystemTagsAndRules();
|
// await syncSystemTagsAndRules();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fire off background sync after a short delay
|
// Fire off background sync after a short delay
|
||||||
@@ -1363,6 +1422,31 @@ export const upsertTagDefinition = async (name: string, value: number, color?: s
|
|||||||
theme: theme !== undefined ? theme : existing.theme
|
theme: theme !== undefined ? theme : existing.theme
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
|
// Check for case-insensitive duplicate
|
||||||
|
const nameLower = name.toLowerCase();
|
||||||
|
const duplicate = store.tagDefinitions.find(d => d.name.toLowerCase() === nameLower);
|
||||||
|
|
||||||
|
if (duplicate) {
|
||||||
|
if (duplicate.name !== name) {
|
||||||
|
// Case difference only -> Update to new casing?
|
||||||
|
// Or just warn? "Tag already exists with different casing".
|
||||||
|
// For now, we'll just return the existing one to be safe, or update it if needed.
|
||||||
|
// A strict interpretation of "There should never be two tags with the same name"
|
||||||
|
// usually implies normalization.
|
||||||
|
// But if the user typed "Work" and "work" exists, maybe they mean "work".
|
||||||
|
toast.error(`Tag "${duplicate.name}" already exists.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Exact match logic handled by 'existing' check above usually, but 'existing' uses strict equality?
|
||||||
|
// The existing check at top of function is strict: `existing = store.tagDefinitions.find(d => d.name === name);`
|
||||||
|
// So this block handles case-insensitive duplicates that weren't caught by strict check.
|
||||||
|
// We should update the existing one if we want to change casing, or just reject.
|
||||||
|
// Rejection is safer for unique constraint.
|
||||||
|
toast.error(`Tag "${duplicate.name}" already exists.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const record = await pb.collection(TAGS_COLLECTION).create({
|
const record = await pb.collection(TAGS_COLLECTION).create({
|
||||||
user: pb.authStore.model?.id,
|
user: pb.authStore.model?.id,
|
||||||
name,
|
name,
|
||||||
@@ -1537,24 +1621,59 @@ export const syncSystemTagsAndRules = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- User Share Rule Sync ---
|
// --- User Share Rule Sync ---
|
||||||
|
// OPTIMIZATION: We no longer create individual share rules for every user-pair.
|
||||||
|
// Instead, we rely on the "Global Rule" (Self-Rule) created above for the user's own name.
|
||||||
|
// When User A tags a task "Bob", the Global Rule for Bob (Owner=Bob, Target=Bob, Tag=Bob)
|
||||||
|
// will pick it up via the updated `shouldTaskBeVisible` logic.
|
||||||
|
|
||||||
|
// If we want to support "Collab" (Edit access), we might still need specific rules or specific shares,
|
||||||
|
// but for "View" access (Handoff), the Global Rule is sufficient.
|
||||||
|
|
||||||
|
// We do NOT delete existing rules here to avoid data loss during transition,
|
||||||
|
// unless strictly requested. The user said "I want to optimize share rules... make them ownerless".
|
||||||
|
// So we stop creating new ones.
|
||||||
|
|
||||||
|
/*
|
||||||
if (user.id !== currentUserId) {
|
if (user.id !== currentUserId) {
|
||||||
const matchingRules = normalizedRules.filter(r =>
|
// Legacy loop removed to prevent exponential growth
|
||||||
r.targetUserId === user.id &&
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Ensure Self-Rule for Current User (Global Subscription) ---
|
||||||
|
// This is the critical piece for "Global Sharing".
|
||||||
|
// We must ensure the user has a rule: Owner=Me, Target=Me, Tag=Me.
|
||||||
|
if (user.id === currentUserId) {
|
||||||
|
const selfRuleExists = normalizedRules.some(r =>
|
||||||
|
r.ownerId === currentUserId &&
|
||||||
|
r.targetUserId === currentUserId &&
|
||||||
r.type === 'tag' &&
|
r.type === 'tag' &&
|
||||||
r.tagName === userName
|
r.tagName === userName
|
||||||
);
|
);
|
||||||
|
|
||||||
if (matchingRules.length === 0) {
|
if (!selfRuleExists) {
|
||||||
await addShareRule('tag', user.id, userName, 'ADD_USER');
|
try {
|
||||||
} else if (matchingRules.length > 1) {
|
const record = await pb.collection(SHARE_RULES_COLLECTION).create({
|
||||||
const [, ...remove] = matchingRules;
|
ownerId: currentUserId,
|
||||||
for (const rule of remove) {
|
targetUserId: currentUserId,
|
||||||
try {
|
type: 'tag',
|
||||||
await pb.collection(SHARE_RULES_COLLECTION).delete(rule.id);
|
tagName: userName,
|
||||||
setStore("shareRules", list => list.filter(r => r.id !== rule.id));
|
share_mode: 'ADD_USER' // Mode doesn't strictly matter for self-rule visibility, but ADD_USER is safe default
|
||||||
} catch (e) {
|
});
|
||||||
console.error("Failed to delete duplicate rule:", e);
|
|
||||||
}
|
// Update local store immediately to ensure loadAllHistory sees it
|
||||||
|
const newRule: ShareRule = {
|
||||||
|
id: record.id,
|
||||||
|
ownerId: currentUserId,
|
||||||
|
targetUserId: currentUserId,
|
||||||
|
type: 'tag',
|
||||||
|
tagName: userName,
|
||||||
|
share_mode: 'ADD_USER'
|
||||||
|
};
|
||||||
|
setStore("shareRules", prev => [...prev, newRule]);
|
||||||
|
|
||||||
|
console.log(`Created self-rule for global sharing: ${userName}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to create self-rule for ${userName}`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1573,6 +1692,14 @@ export const renameTagDefinition = async (oldName: string, newName: string) => {
|
|||||||
if (!def) return;
|
if (!def) return;
|
||||||
|
|
||||||
const finalName = newName.trim();
|
const finalName = newName.trim();
|
||||||
|
const finalNameLower = finalName.toLowerCase();
|
||||||
|
|
||||||
|
// Check availability (case-insensitive)
|
||||||
|
const duplicate = store.tagDefinitions.find(d => d.name.toLowerCase() === finalNameLower && d.id !== def.id);
|
||||||
|
if (duplicate) {
|
||||||
|
toast.error(`Tag "${duplicate.name}" already exists.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pb.collection(TAGS_COLLECTION).update(def.id, { name: finalName });
|
await pb.collection(TAGS_COLLECTION).update(def.id, { name: finalName });
|
||||||
@@ -1621,13 +1748,43 @@ export const loadAllHistory = async () => {
|
|||||||
}).catch(() => []));
|
}).catch(() => []));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Add Share Rules
|
// Add Share Rules
|
||||||
const incomingRules = store.shareRules.filter(r => r.targetUserId === userId);
|
const incomingRules = store.shareRules.filter(r => r.targetUserId === userId);
|
||||||
|
console.log("[DEBUG] loadAllHistory - Incoming Rules for User:", userId, incomingRules);
|
||||||
|
|
||||||
if (incomingRules.length > 0) {
|
if (incomingRules.length > 0) {
|
||||||
|
const rulesToFetch: string[] = [];
|
||||||
|
|
||||||
incomingRules.forEach((rule: any) => {
|
incomingRules.forEach((rule: any) => {
|
||||||
let filter = `user = "${rule.ownerId}"`;
|
// Global Rule (Self-Targeted)
|
||||||
if (rule.type === 'tag' && rule.tagName) filter += ` && tags ~ "${rule.tagName}"`;
|
if (rule.ownerId === rule.targetUserId && rule.type === 'tag' && rule.tagName) {
|
||||||
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({ filter, sort: '-created', fields: LIST_FIELDS, requestKey: null }).catch(() => []));
|
console.log("[DEBUG] Found Self-Rule:", rule.tagName);
|
||||||
|
rulesToFetch.push(`tags ~ "${rule.tagName}"`);
|
||||||
|
} else {
|
||||||
|
// Standard Rule
|
||||||
|
let f = `user = "${rule.ownerId}"`;
|
||||||
|
if (rule.type === 'tag' && rule.tagName) f += ` && tags ~ "${rule.tagName}"`;
|
||||||
|
rulesToFetch.push(f);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optimization: Group Global Rules?
|
||||||
|
// For now, simpler to just push them all.
|
||||||
|
// But if we have many global rules (e.g. buckets), we might want to group them.
|
||||||
|
// The Global Rules usually behave like buckets.
|
||||||
|
// Let's just push them as individual queries for safety.
|
||||||
|
|
||||||
|
rulesToFetch.forEach(filter => {
|
||||||
|
console.log("[DEBUG] Fetching with filter:", filter);
|
||||||
|
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({ filter, sort: '-created', fields: LIST_FIELDS, requestKey: null }).then(res => {
|
||||||
|
console.log(`[DEBUG] Filter "${filter}" returned ${res.length} items`);
|
||||||
|
return res;
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(`[DEBUG] Filter "${filter}" FAILED:`, err);
|
||||||
|
return [];
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1896,6 +2053,23 @@ export const loadShareRules = async () => {
|
|||||||
export const addShareRule = async (type: 'tag' | 'all', targetUserId: string, tagName?: string, share_mode: 'ADD_USER' | 'SEND_TASK' = 'ADD_USER') => {
|
export const addShareRule = async (type: 'tag' | 'all', targetUserId: string, tagName?: string, share_mode: 'ADD_USER' | 'SEND_TASK' = 'ADD_USER') => {
|
||||||
if (!pb.authStore.isValid) return;
|
if (!pb.authStore.isValid) return;
|
||||||
|
|
||||||
|
// Check for duplicates
|
||||||
|
// We check against the local store to avoid network round-trip delay issues
|
||||||
|
const currentUserId = pb.authStore.model?.id;
|
||||||
|
const exists = store.shareRules.some(r =>
|
||||||
|
r.ownerId === currentUserId &&
|
||||||
|
r.targetUserId === targetUserId &&
|
||||||
|
r.type === type &&
|
||||||
|
(type === 'all' || r.tagName === tagName)
|
||||||
|
// We don't strictly check share_mode because having two rules for same tag/person with different modes is confusing.
|
||||||
|
// Usually we update the existing one if mode changes.
|
||||||
|
);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
// toast.info("Share rule already exists.");
|
||||||
|
return; // Silent return or update?
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pb.collection(SHARE_RULES_COLLECTION).create({
|
await pb.collection(SHARE_RULES_COLLECTION).create({
|
||||||
ownerId: pb.authStore.model?.id,
|
ownerId: pb.authStore.model?.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user