user-context migration
This commit is contained in:
+200
-108
@@ -217,8 +217,9 @@ export const startContextPolling = () => {
|
||||
try {
|
||||
// Fetch only recent updates (last 30s) to minimize payload
|
||||
const timeStr = new Date(Date.now() - 30000).toISOString();
|
||||
const contextFilter = buildPersonalAccessFilter([personalOrUserId]);
|
||||
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `${relationFilter("user", personalOrUserId)} && updated >= "${timeStr}"`,
|
||||
filter: `${contextFilter} && updated >= "${timeStr}"`,
|
||||
sort: '-updated',
|
||||
fields: getTaskListFields(),
|
||||
requestKey: 'context-poll'
|
||||
@@ -286,6 +287,7 @@ interface ScopedTaskgridPrefs {
|
||||
collapsedUntilUpdatedTasks?: Record<string, string>;
|
||||
migratedStructuredTagsV2?: boolean;
|
||||
migratedPersonalContextRefsV1?: boolean;
|
||||
migratedContextOwnershipV2?: boolean;
|
||||
lastCompletedTaskCleanupDate?: string; // YYYY-MM-DD, per user/environment
|
||||
}
|
||||
|
||||
@@ -558,14 +560,14 @@ export const getNoteSharePolicy = (note: Pick<Note, "tags"> | null | undefined):
|
||||
note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative";
|
||||
|
||||
export const getTaskSharePolicy = (ref: Pick<TaskShareRef, "kind" | "key" | "policy" | "contextId">): "collaborative" | "handoff" => {
|
||||
if (ref.policy) return ref.policy;
|
||||
|
||||
if (ref.kind === "note") {
|
||||
if (ref.policy) return ref.policy;
|
||||
const note = store.notes.find(existing => existing.id === ref.contextId || existing.key === ref.key);
|
||||
return getNoteSharePolicy(note);
|
||||
}
|
||||
|
||||
const context = getContextByRef(ref as TaskShareRef);
|
||||
if (ref.policy) return ref.policy;
|
||||
if (context?.policy) return context.policy;
|
||||
|
||||
return DEFAULT_SHARE_POLICY_BY_KIND[ref.kind];
|
||||
@@ -868,6 +870,106 @@ const getPersonalContext = (userId = pb.authStore.model?.id) =>
|
||||
) || null
|
||||
: null;
|
||||
|
||||
const getCurrentUserDisplayName = () =>
|
||||
(pb.authStore.model?.name || pb.authStore.model?.email || "").trim() || null;
|
||||
|
||||
const getPersonalContextTag = (userId = pb.authStore.model?.id) => {
|
||||
const context = getPersonalContext(userId);
|
||||
if (context?.displayName) {
|
||||
return buildShareTag(context.displayName);
|
||||
}
|
||||
|
||||
if (userId && userId === pb.authStore.model?.id) {
|
||||
const displayName = getCurrentUserDisplayName();
|
||||
if (displayName) {
|
||||
return buildShareTag(displayName);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
|
||||
const buildPersonalAccessFilter = (userIds: string[], includeLegacyUser = true) => {
|
||||
const clauses = userIds.flatMap(userId => {
|
||||
const nextClauses: string[] = [];
|
||||
const personalTag = getPersonalContextTag(userId);
|
||||
if (personalTag) {
|
||||
nextClauses.push(`tags ~ "${escapeFilterValue(personalTag)}"`);
|
||||
}
|
||||
if (includeLegacyUser) {
|
||||
nextClauses.push(relationFilter("user", userId));
|
||||
}
|
||||
return nextClauses;
|
||||
}).filter(Boolean);
|
||||
|
||||
return clauses.length > 0 ? `(${clauses.join(" || ")})` : "false";
|
||||
};
|
||||
|
||||
const getPersonalContextUserIdFromRef = (ref: TaskShareRef) => {
|
||||
if (ref.kind !== "user") return null;
|
||||
|
||||
const context = getContextByRef(ref);
|
||||
if (context?.targetUserId) {
|
||||
return context.targetUserId;
|
||||
}
|
||||
|
||||
if (ref.key.startsWith("user-")) {
|
||||
return ref.key.replace(/^user-/, "") || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getTaskPersonalRefs = (task: Pick<Task, "shareRefs">) =>
|
||||
task.shareRefs.filter(ref => ref.kind === "user" && !!getPersonalContextUserIdFromRef(ref));
|
||||
|
||||
const getTaskPersonalUserIds = (task: Pick<Task, "shareRefs">) =>
|
||||
[...new Set(getTaskPersonalRefs(task).map(ref => getPersonalContextUserIdFromRef(ref)).filter((value): value is string => !!value))];
|
||||
|
||||
const taskHasLegacyPersonalAccess = (task: Pick<Task, "ownerId" | "shareRefs">, userId: string) =>
|
||||
task.ownerId === userId &&
|
||||
getTaskPersonalRefs(task).length === 0 &&
|
||||
!task.shareRefs.some(ref => ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff");
|
||||
|
||||
const taskHasPersonalContextAccess = (task: Pick<Task, "ownerId" | "shareRefs">, userId: string, allowLegacyFallback = true) =>
|
||||
getTaskPersonalUserIds(task).includes(userId) ||
|
||||
(allowLegacyFallback && taskHasLegacyPersonalAccess(task, userId));
|
||||
|
||||
const resolveMirroredOwnerId = (
|
||||
currentOwnerId: string | null | undefined,
|
||||
shareRefs: TaskShareRef[],
|
||||
preferredOwnerId?: string | null
|
||||
) => {
|
||||
const personalUserIds = [...new Set(
|
||||
shareRefs
|
||||
.filter(ref => ref.kind === "user")
|
||||
.map(ref => getPersonalContextUserIdFromRef(ref))
|
||||
.filter((value): value is string => !!value)
|
||||
)];
|
||||
|
||||
if (personalUserIds.length === 1) {
|
||||
return personalUserIds[0];
|
||||
}
|
||||
|
||||
if (personalUserIds.length > 1 && preferredOwnerId && personalUserIds.includes(preferredOwnerId)) {
|
||||
return preferredOwnerId;
|
||||
}
|
||||
|
||||
return currentOwnerId || null;
|
||||
};
|
||||
|
||||
const withMirroredOwnerId = (
|
||||
currentOwnerId: string | null | undefined,
|
||||
shareRefs: TaskShareRef[],
|
||||
updates: Partial<Task>,
|
||||
preferredOwnerId?: string | null
|
||||
) => ({
|
||||
...updates,
|
||||
ownerId: resolveMirroredOwnerId(currentOwnerId, shareRefs, preferredOwnerId)
|
||||
});
|
||||
|
||||
export const isCurrentUserContextTag = (tag: string) => {
|
||||
const normalizedTag = tag.trim().toLowerCase();
|
||||
if (!normalizedTag.startsWith("@")) return false;
|
||||
@@ -937,69 +1039,34 @@ export const matchesFilter = (task: Task) => {
|
||||
const activeShareContexts = task.shareRefs
|
||||
.map(ref => getContextByRef(ref))
|
||||
.filter((context): context is ShareContext => !!context && !context.deletedAt);
|
||||
const collaborativeUserShare = task.shareRefs.some(ref => {
|
||||
if (ref.kind !== "user" || getTaskSharePolicy(ref) !== "collaborative") return false;
|
||||
const context = getContextByRef(ref);
|
||||
return context?.targetUserId === currentUserId;
|
||||
});
|
||||
const bucketRefs = activeShareContexts.filter(context => context.kind === "bucket");
|
||||
const hasHandoffContext = task.shareRefs.some(ref =>
|
||||
ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff"
|
||||
);
|
||||
const inPinnedBucket = rawBucketRefs.some(ref =>
|
||||
store.subscribedBuckets.includes(ref.contextId) ||
|
||||
store.subscribedBuckets.some(bucketId => store.contexts.find(context => context.id === bucketId)?.key === ref.key)
|
||||
);
|
||||
const inAnyBucket = rawBucketRefs.length > 0;
|
||||
const isPersonalView = ctx === "mine" || (
|
||||
typeof ctx === "object" &&
|
||||
"bucketId" in ctx &&
|
||||
!!personalContext &&
|
||||
ctx.bucketId === personalContext.id
|
||||
);
|
||||
const inPersonalContext = !!personalContext && task.shareRefs.some(ref =>
|
||||
ref.kind === "user" &&
|
||||
(
|
||||
ref.contextId === personalContext.id ||
|
||||
ref.key === personalContext.key ||
|
||||
normalizeContextKey(personalContext.displayName) === ref.key
|
||||
)
|
||||
);
|
||||
|
||||
// 0. Context Filter
|
||||
if (isPersonalView) {
|
||||
if (hasHandoffContext) return false;
|
||||
const isOwner = task.ownerId === currentUserId;
|
||||
if (!isOwner && !collaborativeUserShare && !inPersonalContext) return false;
|
||||
if (!currentUserId || !taskHasPersonalContextAccess(task, currentUserId)) return false;
|
||||
} else if (typeof ctx === 'object' && 'bucketId' in ctx) {
|
||||
const activeBucketContext = store.contexts.find(context => context.id === (ctx as { bucketId: string }).bucketId);
|
||||
const activeBucketKey = activeBucketContext?.key || normalizeContextKey((ctx as { name: string }).name);
|
||||
const inCurrentBucket = rawBucketRefs.some(ref =>
|
||||
ref.contextId === (ctx as { bucketId: string }).bucketId ||
|
||||
ref.key === activeBucketKey
|
||||
) || bucketRefs.some(context => context.id === (ctx as { bucketId: string }).bucketId);
|
||||
const inCurrentBucket = activeBucketContext?.kind === "user"
|
||||
? !!activeBucketContext.targetUserId && taskHasPersonalContextAccess(task, activeBucketContext.targetUserId)
|
||||
: rawBucketRefs.some(ref =>
|
||||
ref.contextId === (ctx as { bucketId: string }).bucketId ||
|
||||
ref.key === activeBucketKey
|
||||
) || bucketRefs.some(context => context.id === (ctx as { bucketId: string }).bucketId);
|
||||
if (!inCurrentBucket) return false;
|
||||
} else {
|
||||
const targetUserId = (ctx as { userId: string }).userId;
|
||||
const targetPersonalContext = getPersonalContext(targetUserId);
|
||||
const inTargetPersonalContext = !!targetPersonalContext && task.shareRefs.some(ref =>
|
||||
ref.kind === "user" &&
|
||||
(
|
||||
ref.contextId === targetPersonalContext.id ||
|
||||
ref.key === targetPersonalContext.key ||
|
||||
normalizeContextKey(targetPersonalContext.displayName) === ref.key
|
||||
)
|
||||
);
|
||||
|
||||
if (targetPersonalContext) {
|
||||
if (!inTargetPersonalContext) return false;
|
||||
} else if (task.ownerId !== targetUserId) {
|
||||
if (!taskHasPersonalContextAccess(task, targetUserId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isPersonalView && inAnyBucket && !inPinnedBucket && task.ownerId !== currentUserId) return false;
|
||||
|
||||
// Hide templates from all regular views
|
||||
if (task.tags?.includes("__template__")) return false;
|
||||
|
||||
@@ -1046,7 +1113,7 @@ export const matchesFilter = (task: Task) => {
|
||||
}
|
||||
|
||||
// Owned By Me
|
||||
if (f.ownedByMe && task.ownerId !== currentUserId) {
|
||||
if (f.ownedByMe && (!currentUserId || !taskHasPersonalContextAccess(task, currentUserId))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1107,7 +1174,7 @@ createRoot(() => {
|
||||
// Calculate Top 20 Tasks
|
||||
const incompleteTasks = store.tasks.filter(t =>
|
||||
!t.completed &&
|
||||
t.ownerId === pb.authStore.model?.id &&
|
||||
taskHasPersonalContextAccess(t, pb.authStore.model?.id || "", true) &&
|
||||
!t.tags?.includes("__template__") &&
|
||||
!t.deletedAt
|
||||
);
|
||||
@@ -1306,7 +1373,9 @@ const buildTaskMigrationPayload = (
|
||||
const ownerContext = task.ownerId ? ownerContextByUserId.get(task.ownerId) : undefined;
|
||||
let nextShareRefs = dedupeShareRefs(task.shareRefs);
|
||||
let nextNoteRefs = dedupeNoteRefs([...task.noteRefs, ...linkedNoteRefs]);
|
||||
const hasHandoffContext = nextShareRefs.some(ref => ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff");
|
||||
const shouldHideFromLegacyOwner = !!task.ownerId &&
|
||||
getTaskPersonalRefs(task).length === 0 &&
|
||||
nextShareRefs.some(ref => ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff");
|
||||
|
||||
if (ownerContext) {
|
||||
const matchesOwnerContext = (ref: TaskShareRef) =>
|
||||
@@ -1316,7 +1385,7 @@ const buildTaskMigrationPayload = (
|
||||
normalizeContextKey(ownerContext.displayName) === ref.key
|
||||
);
|
||||
|
||||
if (hasHandoffContext) {
|
||||
if (shouldHideFromLegacyOwner) {
|
||||
nextShareRefs = nextShareRefs.filter(ref => !matchesOwnerContext(ref));
|
||||
} else if (!nextShareRefs.some(matchesOwnerContext)) {
|
||||
nextShareRefs = [
|
||||
@@ -1332,12 +1401,18 @@ const buildTaskMigrationPayload = (
|
||||
}
|
||||
|
||||
nextShareRefs = dedupeShareRefs(nextShareRefs);
|
||||
const mirroredOwnerId = resolveMirroredOwnerId(
|
||||
task.ownerId || fallbackUserId || pb.authStore.model?.id || null,
|
||||
nextShareRefs,
|
||||
task.ownerId || fallbackUserId || null
|
||||
);
|
||||
|
||||
return {
|
||||
tags: buildTaskTags(task.labelTags, nextShareRefs, nextNoteRefs, favoriteTag),
|
||||
labelTags: task.labelTags,
|
||||
shareRefs: nextShareRefs,
|
||||
noteRefs: nextNoteRefs,
|
||||
ownerId: mirroredOwnerId,
|
||||
createdBy: task.createdBy || task.ownerId || fallbackUserId || pb.authStore.model?.id || ""
|
||||
};
|
||||
};
|
||||
@@ -1612,10 +1687,22 @@ export const loadNoteContent = async (id: string) => {
|
||||
// Helper to check if a task should be visible based on ownership, shares, or rules
|
||||
const shouldTaskBeVisible = (task: any, currentUserId: string): boolean => {
|
||||
const mappedTask = "shareRefs" in task ? task as Task : mapRecordToTask(task);
|
||||
if (mappedTask.ownerId === currentUserId) return true;
|
||||
if (store.supervisedContexts.some(context => context.ownerUserId === mappedTask.ownerId)) return true;
|
||||
if (taskHasPersonalContextAccess(mappedTask, currentUserId)) return true;
|
||||
if (store.supervisedContexts.some(context => taskHasPersonalContextAccess(mappedTask, context.ownerUserId))) return true;
|
||||
|
||||
if (mappedTask.shareRefs.some(ref => ref.kind === "bucket")) return true;
|
||||
const hasSubscribedBucket = mappedTask.shareRefs.some(ref =>
|
||||
ref.kind === "bucket" && (
|
||||
store.subscribedBuckets.includes(ref.contextId) ||
|
||||
store.subscribedBuckets.some(bucketId => store.contexts.find(context => context.id === bucketId)?.key === ref.key)
|
||||
)
|
||||
);
|
||||
if (hasSubscribedBucket) return true;
|
||||
|
||||
const hasAccessibleNote = mappedTask.noteRefs.some(ref => {
|
||||
const note = getNoteByRef(ref);
|
||||
return !!note && (!note.isPrivate || note.user === currentUserId);
|
||||
});
|
||||
if (hasAccessibleNote) return true;
|
||||
|
||||
return mappedTask.shareRefs.some(ref => {
|
||||
if (ref.kind !== "user" || getTaskSharePolicy(ref) !== "collaborative") return false;
|
||||
@@ -1879,7 +1966,7 @@ const syncUserContexts = async () => {
|
||||
key: buildUserContextKey(user.id),
|
||||
displayName,
|
||||
kind: "user",
|
||||
policy: "handoff",
|
||||
policy: "collaborative",
|
||||
targetUserId: user.id
|
||||
}, { requestKey: null }).catch(() => null);
|
||||
if (created) {
|
||||
@@ -1933,7 +2020,7 @@ const syncSupervisedContexts = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const runPersonalContextMigration = async (force = false) => {
|
||||
const runContextOwnershipMigration = async (force = false) => {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
if (!currentUserId) return;
|
||||
|
||||
@@ -1942,19 +2029,23 @@ const runPersonalContextMigration = async (force = false) => {
|
||||
const rawPrefs = userRec.Taskgrid_pref || {};
|
||||
const prefs = readScopedPrefs(rawPrefs);
|
||||
|
||||
if (!force && prefs.migratedPersonalContextRefsV1) {
|
||||
if (!force && prefs.migratedContextOwnershipV2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contexts = store.contexts.length > 0
|
||||
? store.contexts
|
||||
: await loadContextsIntoStore();
|
||||
await syncUserContexts();
|
||||
const contexts = store.contexts.length > 0 ? store.contexts : await loadContextsIntoStore();
|
||||
const ownerContextByUserId = new Map(
|
||||
contexts
|
||||
.filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt)
|
||||
.map(context => [context.targetUserId as string, context])
|
||||
);
|
||||
const noteLinksByTaskId = buildNoteLinkMap(store.notes);
|
||||
const noteRecords = await pb.collection(NOTES_COLLECTION).getFullList({
|
||||
fields: NOTE_LIST_FIELDS,
|
||||
requestKey: null
|
||||
}).catch(() => []);
|
||||
const allNotes = noteRecords.map(record => mapRecordToNote(record));
|
||||
const noteLinksByTaskId = buildNoteLinkMap(allNotes);
|
||||
|
||||
const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
fields: getTaskListFields(),
|
||||
@@ -1976,19 +2067,27 @@ const runPersonalContextMigration = async (force = false) => {
|
||||
const sameShareRefs = JSON.stringify(payload.shareRefs) === JSON.stringify(task.shareRefs);
|
||||
const sameNoteRefs = JSON.stringify(payload.noteRefs) === JSON.stringify(task.noteRefs);
|
||||
const sameTags = JSON.stringify(payload.tags) === JSON.stringify(task.tags);
|
||||
if (sameShareRefs && sameNoteRefs && sameTags && payload.createdBy === task.createdBy) continue;
|
||||
const sameOwnerId = payload.ownerId === task.ownerId;
|
||||
if (sameShareRefs && sameNoteRefs && sameTags && payload.createdBy === task.createdBy && sameOwnerId) continue;
|
||||
|
||||
await pb.collection(TASGRID_COLLECTION).update(task.id, payload, { requestKey: null });
|
||||
const pbPayload = {
|
||||
...payload,
|
||||
user: payload.ownerId || task.ownerId || null
|
||||
} as any;
|
||||
delete pbPayload.ownerId;
|
||||
|
||||
await pb.collection(TASGRID_COLLECTION).update(task.id, pbPayload, { requestKey: null });
|
||||
}
|
||||
|
||||
await pb.collection('users').update(currentUserId, {
|
||||
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
|
||||
...prefs,
|
||||
migratedPersonalContextRefsV1: true
|
||||
migratedPersonalContextRefsV1: true,
|
||||
migratedContextOwnershipV2: true
|
||||
})
|
||||
}, { requestKey: null });
|
||||
} catch (err) {
|
||||
console.error("[MIGRATION] Failed personal context backfill:", err);
|
||||
console.error("[MIGRATION] Failed context ownership migration:", err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2001,7 +2100,7 @@ export const runDevDataMigration = async () => {
|
||||
|
||||
await toast.promise((async () => {
|
||||
await runLegacyTagMigration(true);
|
||||
await runPersonalContextMigration(true);
|
||||
await runContextOwnershipMigration(true);
|
||||
await loadContextsIntoStore();
|
||||
await syncSupervisedContexts();
|
||||
return "Dev data migration completed.";
|
||||
@@ -2027,7 +2126,7 @@ export const runProdDataMigration = async () => {
|
||||
|
||||
await toast.promise((async () => {
|
||||
await runLegacyTagMigration(true);
|
||||
await runPersonalContextMigration(true);
|
||||
await runContextOwnershipMigration(true);
|
||||
await loadContextsIntoStore();
|
||||
await syncSupervisedContexts();
|
||||
return "Production data migration completed.";
|
||||
@@ -2065,8 +2164,9 @@ export const initStore = async () => {
|
||||
res.sort((a, b) => cachedQuickload.indexOf(a.id) - cachedQuickload.indexOf(b.id));
|
||||
focusRecords = { items: res };
|
||||
} else {
|
||||
const personalAccessFilter = buildPersonalAccessFilter([currentUserId]);
|
||||
focusRecords = await pb.collection(TASGRID_COLLECTION).getList(1, 30, {
|
||||
filter: `${relationFilter("user", currentUserId)} && completed = false && deletedAt = ""`,
|
||||
filter: `${personalAccessFilter} && completed = false && deletedAt = ""`,
|
||||
sort: '-priority,dueDate',
|
||||
requestKey: null
|
||||
});
|
||||
@@ -2206,18 +2306,13 @@ export const initStore = async () => {
|
||||
const subscribedBucketTags = getSubscribedBucketContexts()
|
||||
.map(context => buildShareTag(context.displayName));
|
||||
const supervisedUserIds = store.supervisedContexts.map(context => context.ownerUserId);
|
||||
const collaborativeKeys = store.contexts
|
||||
.filter(context =>
|
||||
!context.deletedAt &&
|
||||
context.kind === "user" &&
|
||||
context.targetUserId === currentUserId
|
||||
)
|
||||
.map(context => buildShareTag(context.displayName));
|
||||
const currentUserAccessFilter = buildPersonalAccessFilter(currentUserId ? [currentUserId] : []);
|
||||
const supervisedAccessFilter = buildPersonalAccessFilter(supervisedUserIds);
|
||||
|
||||
// 2a. Fetch ALL Incomplete Tasks (Metadata Only) to ensure availability
|
||||
const incompletePromises = [
|
||||
pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `${relationFilter("user", currentUserId)} && completed = false`,
|
||||
filter: `${currentUserAccessFilter} && completed = false`,
|
||||
sort: '-created',
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
@@ -2228,14 +2323,8 @@ export const initStore = async () => {
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
}).catch(() => []) : Promise.resolve([]),
|
||||
(collaborativeKeys.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `(${collaborativeKeys.map(tag => `tags ~ "${tag}"`).join(' || ')}) && completed = false`,
|
||||
sort: '-created',
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
}).catch(() => []) : Promise.resolve([]),
|
||||
(supervisedUserIds.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `(${supervisedUserIds.map(id => relationFilter("user", id)).join(' || ')}) && completed = false`,
|
||||
filter: `${supervisedAccessFilter} && completed = false`,
|
||||
sort: '-created',
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
@@ -2302,8 +2391,9 @@ export const initStore = async () => {
|
||||
|
||||
if (remainingSlots > 0) {
|
||||
try {
|
||||
const completedAccessFilter = buildPersonalAccessFilter(currentUserId ? [currentUserId] : []);
|
||||
const recentCompleted = await pb.collection(TASGRID_COLLECTION).getList(1, remainingSlots, {
|
||||
filter: `${relationFilter("user", currentUserId)} && completed = true`,
|
||||
filter: `${completedAccessFilter} && completed = true`,
|
||||
sort: '-updated',
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
@@ -2346,7 +2436,7 @@ export const initStore = async () => {
|
||||
// Run one-time migration automatically only in dev data mode.
|
||||
if (TASGRID_IS_DEV_DATA) {
|
||||
await runLegacyTagMigration();
|
||||
await runPersonalContextMigration();
|
||||
await runContextOwnershipMigration();
|
||||
}
|
||||
|
||||
// Separate Templates load (less critical)
|
||||
@@ -2622,7 +2712,13 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
||||
// Apply Handoff Logic (e.g. transfer ownership if tagging for another user)
|
||||
// We treat the entire new task as "updates" to check against itself
|
||||
const handoffUpdates = checkForHandoffs(initialTask, initialTask);
|
||||
const newTask = { ...initialTask, ...handoffUpdates };
|
||||
const mirroredUpdates = withMirroredOwnerId(
|
||||
initialTask.ownerId,
|
||||
handoffUpdates.shareRefs || initialTask.shareRefs,
|
||||
handoffUpdates,
|
||||
handoffUpdates.ownerId || initialTask.ownerId
|
||||
);
|
||||
const newTask = { ...initialTask, ...mirroredUpdates };
|
||||
|
||||
// Optimistic UI update
|
||||
setStore("tasks", (t) => [newTask, ...t]);
|
||||
@@ -2710,7 +2806,7 @@ export const copyTask = async (id: string) => {
|
||||
|
||||
const checkForHandoffs = (task: Task, updates: Partial<Task>): Partial<Task> => {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
if (!currentUserId || task.ownerId !== currentUserId) return updates;
|
||||
if (!currentUserId || !taskHasPersonalContextAccess(task, currentUserId)) return updates;
|
||||
|
||||
const refs = updates.shareRefs || task.shareRefs;
|
||||
const senderPersonalContext = getPersonalContext(currentUserId);
|
||||
@@ -2724,6 +2820,12 @@ const checkForHandoffs = (task: Task, updates: Partial<Task>): Partial<Task> =>
|
||||
|
||||
for (const ref of refs) {
|
||||
const context = getContextByRef(ref);
|
||||
if (senderPersonalContext && ref.kind === "user" && (
|
||||
ref.contextId === senderPersonalContext.id ||
|
||||
ref.key === senderPersonalContext.key
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
if (!context || getTaskSharePolicy(ref) !== "handoff") continue;
|
||||
const nextShareRefs = stripSenderPersonalContext(refs);
|
||||
const labelTags = updates.labelTags || task.labelTags;
|
||||
@@ -2801,6 +2903,13 @@ export const updateTask = async (id: string, updates: Partial<Task>) => {
|
||||
);
|
||||
}
|
||||
finalUpdates = checkForHandoffs(currentTask, finalUpdates);
|
||||
const mirroredShareRefs = finalUpdates.shareRefs || currentTask.shareRefs;
|
||||
finalUpdates = withMirroredOwnerId(
|
||||
finalUpdates.ownerId ?? currentTask.ownerId,
|
||||
mirroredShareRefs,
|
||||
finalUpdates,
|
||||
finalUpdates.ownerId ?? currentTask.ownerId
|
||||
);
|
||||
}
|
||||
|
||||
// Optimistic update with timestamp to prevent stale realtime events
|
||||
@@ -3141,16 +3250,11 @@ export const loadAllHistory = async () => {
|
||||
const bucketTags = getSubscribedBucketContexts()
|
||||
.map(context => buildShareTag(context.displayName));
|
||||
const supervisedUserIds = store.supervisedContexts.map(context => context.ownerUserId);
|
||||
const collaborativeTags = store.contexts
|
||||
.filter(context =>
|
||||
!context.deletedAt &&
|
||||
context.kind === "user" &&
|
||||
context.targetUserId === userId
|
||||
)
|
||||
.map(context => buildShareTag(context.displayName));
|
||||
const userAccessFilter = buildPersonalAccessFilter([userId]);
|
||||
const supervisedAccessFilter = buildPersonalAccessFilter(supervisedUserIds);
|
||||
|
||||
const promises = [
|
||||
pb.collection(TASGRID_COLLECTION).getFullList({ filter: relationFilter("user", userId), sort: '-created', fields: getTaskListFields(), requestKey: null })
|
||||
pb.collection(TASGRID_COLLECTION).getFullList({ filter: userAccessFilter, sort: '-created', fields: getTaskListFields(), requestKey: null })
|
||||
];
|
||||
|
||||
if (bucketTags.length > 0) {
|
||||
@@ -3162,18 +3266,9 @@ export const loadAllHistory = async () => {
|
||||
}).catch(() => []));
|
||||
}
|
||||
|
||||
if (collaborativeTags.length > 0) {
|
||||
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: collaborativeTags.map(tag => `tags ~ "${tag}"`).join(' || '),
|
||||
sort: '-created',
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
}).catch(() => []));
|
||||
}
|
||||
|
||||
if (supervisedUserIds.length > 0) {
|
||||
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: supervisedUserIds.map(id => relationFilter("user", id)).join(' || '),
|
||||
filter: supervisedAccessFilter,
|
||||
sort: '-created',
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
@@ -3406,10 +3501,7 @@ export const loadTasksForOwner = async (userId: string) => {
|
||||
if (!pb.authStore.isValid || !userId) return;
|
||||
|
||||
try {
|
||||
const targetPersonalContext = getPersonalContext(userId);
|
||||
const filter = targetPersonalContext
|
||||
? `tags ~ "${buildShareTag(targetPersonalContext.displayName)}"`
|
||||
: relationFilter("user", userId);
|
||||
const filter = buildPersonalAccessFilter([userId]);
|
||||
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter,
|
||||
sort: '-created',
|
||||
|
||||
Reference in New Issue
Block a user