diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..a125f1a --- /dev/null +++ b/.env.production @@ -0,0 +1,2 @@ +VITE_TASGRID_ENABLE_PROD_MIGRATION=true +VITE_TASGRID_MIGRATION_ADMIN_EMAILS=tcardoza@cardoza.construction \ No newline at end of file diff --git a/src/lib/app-config.ts b/src/lib/app-config.ts index 839f31d..7093567 100644 --- a/src/lib/app-config.ts +++ b/src/lib/app-config.ts @@ -2,10 +2,17 @@ const rawMode = (import.meta.env.VITE_TASGRID_DATA_MODE || "prod").toLowerCase() export const TASGRID_DATA_MODE = rawMode === "dev" ? "dev" : "prod"; export const TASGRID_IS_DEV_DATA = TASGRID_DATA_MODE === "dev"; +const rawMigrationAdmins = import.meta.env.VITE_TASGRID_MIGRATION_ADMIN_EMAILS || ""; +const rawMigrationEnabled = (import.meta.env.VITE_TASGRID_ENABLE_PROD_MIGRATION || "").toLowerCase(); export const POCKETBASE_URL = import.meta.env.VITE_POCKETBASE_URL || "https://pocketbase.ccllc.pro"; export const AUTH_STORE_KEY = TASGRID_IS_DEV_DATA ? "tasgrid_dev_auth" : "tasgrid_auth"; export const STORAGE_KEY_PREFIX = TASGRID_IS_DEV_DATA ? "tasgrid_dev" : "tasgrid"; export const PWA_CACHE_SUFFIX = TASGRID_IS_DEV_DATA ? "dev" : "prod"; +export const PROD_MIGRATION_ENABLED = rawMigrationEnabled === "true"; +export const PROD_MIGRATION_ADMIN_EMAILS = rawMigrationAdmins + .split(",") + .map((email: string) => email.trim().toLowerCase()) + .filter(Boolean); export const withDataSuffix = (base: string) => TASGRID_IS_DEV_DATA ? `${base}_Dev` : base; diff --git a/src/store/index.ts b/src/store/index.ts index 6fa238b..4505857 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -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(); + 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(); + 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(); + 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, + 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 { diff --git a/src/views/SettingsView.tsx b/src/views/SettingsView.tsx index d47e0f7..5632f25 100644 --- a/src/views/SettingsView.tsx +++ b/src/views/SettingsView.tsx @@ -1,6 +1,6 @@ import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js"; import { ThemeToggle } from "../components/ThemeToggle"; -import { store, restoreTask, deleteTaskPermanently, restoreNote, deleteNotePermanently, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, updateShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription, updateUserContextPolicy, savePersonalContextSupervisors } from "@/store"; +import { store, restoreTask, deleteTaskPermanently, restoreNote, deleteNotePermanently, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, updateShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription, updateUserContextPolicy, savePersonalContextSupervisors, runDevDataMigration, runProdDataMigration, canRunProdMigration } from "@/store"; import { useTheme } from "@/components/ThemeProvider"; import { Trash2, Undo2, ArrowLeftRight, Tag, ChevronDown, ChevronRight, Share2, Users, HelpCircle, Copy, Plus, Type, Upload, Box, Edit2, Archive } from "lucide-solid"; import { TagPicker } from "@/components/TagPicker"; @@ -10,6 +10,7 @@ import { toast } from "solid-sonner"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { PRIORITY_OPTIONS, URGENCY_OPTIONS } from "@/lib/constants"; import { pb } from "@/lib/pocketbase"; +import { TASGRID_IS_DEV_DATA } from "@/lib/app-config"; import { createEffect } from "solid-js"; const TaskEditor = lazy(() => import("../components/TaskEditor").then(m => ({ default: m.TaskEditor }))); @@ -23,6 +24,10 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props const [isSharingOpen, setIsSharingOpen] = createSignal(false); const [isImportOpen, setIsImportOpen] = createSignal(false); const [isBucketsOpen, setIsBucketsOpen] = createSignal(false); + const [isDevToolsOpen, setIsDevToolsOpen] = createSignal(false); + const [isProdMigrationOpen, setIsProdMigrationOpen] = createSignal(false); + const [isRunningDevMigration, setIsRunningDevMigration] = createSignal(false); + const [isRunningProdMigration, setIsRunningProdMigration] = createSignal(false); const [expandedTemplateId, setExpandedTemplateId] = createSignal(null); const [shareUserId, setShareUserId] = createSignal(""); const [shareType, setShareType] = createSignal<'all' | 'tag'>('tag'); @@ -1053,6 +1058,102 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props + +
+

Dev Tools

+
+
setIsDevToolsOpen(!isDevToolsOpen())} + > +
+

+ + Dev Data Migration +

+

+ Rebuild canonical note keys, task `shareRefs` and `noteRefs`, personal user contexts, and bucket handoff cleanup for dev data. +

+
+
+ {isDevToolsOpen() ? : } +
+
+ + +
+

+ This is a dev-only repair tool. It updates notes and tasks in the current `*_Dev` collections to the latest key and context model. +

+ +
+
+
+
+
+ + +
+

Production Migration

+
+
setIsProdMigrationOpen(!isProdMigrationOpen())} + > +
+

+ + Prod Data Migration +

+

+ Admin-only one-shot migration for production tasks, notes, contexts, and note-task links. +

+
+
+ {isProdMigrationOpen() ? : } +
+
+ + +
+

+ Run this only after the PocketBase production schema is ready and you have a fresh backup. This updates live production data in place. +

+ +
+
+
+
+
+ {/* Organization Category */}

Organization