pre-migration prepped
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m30s

This commit is contained in:
2026-03-19 19:27:29 -05:00
parent 2e2b31262b
commit ef6c43f0c2
4 changed files with 315 additions and 57 deletions
+204 -56
View File
@@ -3,7 +3,7 @@ import { createSignal, createEffect, createRoot } from "solid-js";
import { pb } from "@/lib/pocketbase";
import { toast } from "solid-sonner";
import { CONTEXTS_COLLECTION, NOTES_COLLECTION, TASGRID_COLLECTION, URGENCY_HOURS, SIZE_HOURS } from "@/lib/constants";
import { STORAGE_KEY_PREFIX, TASGRID_DATA_MODE } from "@/lib/app-config";
import { STORAGE_KEY_PREFIX, TASGRID_DATA_MODE, TASGRID_IS_DEV_DATA, PROD_MIGRATION_ADMIN_EMAILS, PROD_MIGRATION_ENABLED } from "@/lib/app-config";
import { buildNoteTag, buildShareTag, normalizeContextKey, normalizeNoteKey, parseTags } from "@/lib/tags";
// Helper to get the current user's personalized favorite tag
@@ -536,6 +536,26 @@ const buildTaskTags = (
return [...new Set(tags.filter(Boolean))];
};
const dedupeShareRefs = (refs: TaskShareRef[]) => {
const seen = new Set<string>();
return refs.filter(ref => {
const key = `${ref.kind}:${ref.contextId}:${ref.key}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const dedupeNoteRefs = (refs: NoteRef[]) => {
const seen = new Set<string>();
return refs.filter(ref => {
const key = `${ref.noteId}:${ref.key}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const withActiveContextTags = (rawTags: string[]) => {
const nextTags = [...rawTags];
const ctx = currentTaskContext();
@@ -586,6 +606,17 @@ const setContexts = (contexts: ShareContext[]) => {
setStore("tagDefinitions", reconcile(mergedDefs));
};
const loadContextsIntoStore = async () => {
const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({
sort: 'displayName,key',
requestKey: null
}).catch(() => []);
const nextContexts = contextRecords.map(mapRecordToContext);
setContexts(nextContexts);
setStore("shareRules", nextContexts.map(mapContextToShareRule));
return nextContexts;
};
const getPersonalContext = (userId = pb.authStore.model?.id) =>
userId
? store.contexts.find(context =>
@@ -631,7 +662,11 @@ export const matchesFilter = (task: Task) => {
);
const inPersonalContext = !!personalContext && task.shareRefs.some(ref =>
ref.kind === "user" &&
(ref.contextId === personalContext.id || ref.key === personalContext.key)
(
ref.contextId === personalContext.id ||
ref.key === personalContext.key ||
normalizeContextKey(personalContext.displayName) === ref.key
)
);
// 0. Context Filter
@@ -905,6 +940,66 @@ const mapRecordToTask = (r: any): Task => {
};
};
const buildNoteLinkMap = (notes: Note[]) => {
const links = new Map<string, NoteRef[]>();
for (const note of notes) {
for (const taskId of note.tasks || []) {
const existing = links.get(taskId) || [];
existing.push({ noteId: note.id, key: note.key });
links.set(taskId, existing);
}
}
return links;
};
const buildTaskMigrationPayload = (
task: Task,
ownerContextByUserId: Map<string, ShareContext>,
linkedNoteRefs: NoteRef[] = [],
fallbackUserId?: string
) => {
const favoriteTag = task.tags.find(tag => tag.endsWith("_favorite__"));
const ownerContext = task.ownerId ? ownerContextByUserId.get(task.ownerId) : undefined;
let nextShareRefs = dedupeShareRefs(task.shareRefs);
let nextNoteRefs = dedupeNoteRefs([...task.noteRefs, ...linkedNoteRefs]);
const hasHandoffBucket = nextShareRefs.some(ref => {
const context = getContextByRef(ref);
return context?.kind === "bucket" && context.policy === "handoff";
});
if (ownerContext) {
const matchesOwnerContext = (ref: TaskShareRef) =>
ref.kind === "user" && (
ref.contextId === ownerContext.id ||
ref.key === ownerContext.key ||
normalizeContextKey(ownerContext.displayName) === ref.key
);
if (hasHandoffBucket) {
nextShareRefs = nextShareRefs.filter(ref => !matchesOwnerContext(ref));
} else if (!nextShareRefs.some(matchesOwnerContext)) {
nextShareRefs = [
{
contextId: ownerContext.id,
key: ownerContext.key,
kind: "user" as const
},
...nextShareRefs
];
}
}
nextShareRefs = dedupeShareRefs(nextShareRefs);
return {
tags: buildTaskTags(task.labelTags, nextShareRefs, nextNoteRefs, favoriteTag),
labelTags: task.labelTags,
shareRefs: nextShareRefs,
noteRefs: nextNoteRefs,
createdBy: task.createdBy || task.ownerId || fallbackUserId || pb.authStore.model?.id || ""
};
};
export const uploadTaskAttachment = async (taskId: string, file: File): Promise<{ url: string, filename: string }> => {
try {
const formData = new FormData();
@@ -1247,7 +1342,7 @@ export const subscribeToRealtime = async () => {
});
};
const runLegacyTagMigration = async () => {
const runLegacyTagMigration = async (force = false) => {
const currentUserId = pb.authStore.model?.id;
if (!currentUserId) return;
@@ -1256,13 +1351,45 @@ const runLegacyTagMigration = async () => {
const rawPrefs = userRec.Taskgrid_pref || {};
const prefs = readScopedPrefs(rawPrefs);
if (prefs.migratedStructuredTagsV2) {
if (!force && prefs.migratedStructuredTagsV2) {
return; // Already migrated
}
console.log("[MIGRATION] Starting structured tag migration...");
await syncUserContexts();
const 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 notes = await pb.collection(NOTES_COLLECTION).getFullList({
requestKey: null
});
const migratedNotes: Note[] = [];
for (const noteRecord of notes) {
const key = normalizeNoteKey(noteRecord.key || noteRecord.title || noteRecord.id);
const existingTags = Array.isArray(noteRecord.tags) ? noteRecord.tags.filter(Boolean) : [];
const nextTags = [...new Set([buildNoteTag(key), ...existingTags])];
const nextTasks = [...new Set((Array.isArray(noteRecord.tasks) ? noteRecord.tasks : []).filter(Boolean))];
await pb.collection(NOTES_COLLECTION).update(noteRecord.id, {
key,
tags: nextTags,
tasks: nextTasks
}, { requestKey: null });
migratedNotes.push({
...mapRecordToNote(noteRecord),
key,
tags: nextTags,
tasks: nextTasks
});
}
setStore("notes", migratedNotes);
const noteLinksByTaskId = buildNoteLinkMap(migratedNotes);
const tasks = await pb.collection(TASGRID_COLLECTION).getFullList({
requestKey: null
@@ -1270,24 +1397,13 @@ const runLegacyTagMigration = async () => {
for (const taskRecord of tasks) {
if (taskRecord.tags?.includes("__template__")) continue;
const task = mapRecordToTask(taskRecord);
await pb.collection(TASGRID_COLLECTION).update(task.id, {
tags: task.tags,
labelTags: task.labelTags,
shareRefs: task.shareRefs,
noteRefs: task.noteRefs,
createdBy: task.createdBy || task.ownerId || currentUserId
}, { requestKey: null });
}
const notes = await pb.collection(NOTES_COLLECTION).getFullList({
requestKey: null
});
for (const noteRecord of notes) {
const note = mapRecordToNote(noteRecord);
await pb.collection(NOTES_COLLECTION).update(note.id, {
key: note.key,
tags: note.tags
}, { requestKey: null });
const payload = buildTaskMigrationPayload(
task,
ownerContextByUserId,
noteLinksByTaskId.get(task.id) || [],
currentUserId
);
await pb.collection(TASGRID_COLLECTION).update(task.id, payload, { requestKey: null });
}
const nextPrefs = { ...prefs, migratedStructuredTagsV2: true };
@@ -1331,13 +1447,7 @@ const syncUserContexts = async () => {
}
if (createdAny || store.contexts.length === 0) {
const nextContextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({
sort: 'displayName,key',
requestKey: null
}).catch(() => []);
const nextContexts = nextContextRecords.map(mapRecordToContext);
setContexts(nextContexts);
setStore("shareRules", nextContexts.map(mapContextToShareRule));
await loadContextsIntoStore();
}
};
@@ -1382,7 +1492,7 @@ const syncSupervisedContexts = async () => {
}
};
const runPersonalContextMigration = async () => {
const runPersonalContextMigration = async (force = false) => {
const currentUserId = pb.authStore.model?.id;
if (!currentUserId) return;
@@ -1391,18 +1501,19 @@ const runPersonalContextMigration = async () => {
const rawPrefs = userRec.Taskgrid_pref || {};
const prefs = readScopedPrefs(rawPrefs);
if (prefs.migratedPersonalContextRefsV1) {
if (!force && prefs.migratedPersonalContextRefsV1) {
return;
}
const contexts = store.contexts.length > 0
? store.contexts
: (await pb.collection(CONTEXTS_COLLECTION).getFullList({ requestKey: null })).map(mapRecordToContext);
: 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 taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
fields: LIST_FIELDS,
@@ -1414,30 +1525,19 @@ const runPersonalContextMigration = async () => {
const task = mapRecordToTask(taskRecord);
if (!task.ownerId) continue;
const ownerContext = ownerContextByUserId.get(task.ownerId);
if (!ownerContext) continue;
const alreadyHasOwnerContext = task.shareRefs.some(ref =>
ref.kind === "user" &&
(ref.contextId === ownerContext.id || ref.key === ownerContext.key)
const payload = buildTaskMigrationPayload(
task,
ownerContextByUserId,
noteLinksByTaskId.get(task.id) || [],
currentUserId
);
if (alreadyHasOwnerContext) continue;
const favoriteTag = task.tags.find(tag => tag.endsWith("_favorite__"));
const nextShareRefs = [
{
contextId: ownerContext.id,
key: ownerContext.key,
kind: "user" as const
},
...task.shareRefs
];
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;
await pb.collection(TASGRID_COLLECTION).update(task.id, {
shareRefs: nextShareRefs,
tags: buildTaskTags(task.labelTags, nextShareRefs, task.noteRefs, favoriteTag)
}, { requestKey: null });
await pb.collection(TASGRID_COLLECTION).update(task.id, payload, { requestKey: null });
}
await pb.collection('users').update(currentUserId, {
@@ -1451,6 +1551,52 @@ const runPersonalContextMigration = async () => {
}
};
export const runDevDataMigration = async () => {
if (!pb.authStore.isValid) return;
if (!TASGRID_IS_DEV_DATA) {
toast.error("Dev migration is only available in dev data mode.");
return;
}
await toast.promise((async () => {
await runLegacyTagMigration(true);
await runPersonalContextMigration(true);
await loadContextsIntoStore();
await syncSupervisedContexts();
return "Dev data migration completed.";
})(), {
loading: "Running dev data migration...",
success: (message) => message,
error: "Dev data migration failed."
});
};
export const canRunProdMigration = () => {
if (TASGRID_IS_DEV_DATA || !PROD_MIGRATION_ENABLED) return false;
const email = (pb.authStore.model?.email || "").trim().toLowerCase();
return !!email && PROD_MIGRATION_ADMIN_EMAILS.includes(email);
};
export const runProdDataMigration = async () => {
if (!pb.authStore.isValid) return;
if (!canRunProdMigration()) {
toast.error("Prod migration is not enabled for this account.");
return;
}
await toast.promise((async () => {
await runLegacyTagMigration(true);
await runPersonalContextMigration(true);
await loadContextsIntoStore();
await syncSupervisedContexts();
return "Production data migration completed.";
})(), {
loading: "Running production data migration...",
success: (message) => message,
error: "Production data migration failed."
});
};
export const initStore = async () => {
if (!pb.authStore.isValid || store.isInitializing) return;
@@ -1744,9 +1890,11 @@ export const initStore = async () => {
// Fire off background sync
await backgroundSync();
// Run one-time migration for legacy tags
await runLegacyTagMigration();
await runPersonalContextMigration();
// Run one-time migration automatically only in dev data mode.
if (TASGRID_IS_DEV_DATA) {
await runLegacyTagMigration();
await runPersonalContextMigration();
}
// Separate Templates load (less critical)
try {