user-context migration
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m21s

This commit is contained in:
2026-03-24 18:46:36 -05:00
parent d33a838aec
commit 52786dcd44
8 changed files with 12590 additions and 108 deletions
@@ -0,0 +1,200 @@
[
{
"id": "agjvjaleo6zfqe3",
"key": "user-oz8sxsyg8hznqa6",
"displayName": "IT",
"kind": "user",
"policy": "collaborative",
"targetUserId": "oz8sxsyg8hznqa6",
"deletedAt": null
},
{
"id": "u3julc5juv1c5pq",
"key": "user-2lmwfhtdwuexmiv",
"displayName": "Ezekiel Ewing",
"kind": "user",
"policy": "collaborative",
"targetUserId": "2lmwfhtdwuexmiv",
"deletedAt": null
},
{
"id": "73z3dp9b6alhbl4",
"key": "user-1hbif9dbqng86h9",
"displayName": "Alfonso Ewing",
"kind": "user",
"policy": "collaborative",
"targetUserId": "1hbif9dbqng86h9",
"deletedAt": null
},
{
"id": "p65sesgbxah4chu",
"key": "user-fdve3i2gop4huym",
"displayName": "Heidi Mahan",
"kind": "user",
"policy": "collaborative",
"targetUserId": "fdve3i2gop4huym",
"deletedAt": null
},
{
"id": "vo9x5s7x9jjq6zw",
"key": "user-ee4c6xmjrhypv4f",
"displayName": "Anita Pence",
"kind": "user",
"policy": "collaborative",
"targetUserId": null,
"deletedAt": null
},
{
"id": "ez800j2vp4tp0tk",
"key": "user-h9uihmnkwngdu8y",
"displayName": "Beth Cardoza",
"kind": "user",
"policy": "collaborative",
"targetUserId": "h9uihmnkwngdu8y",
"deletedAt": null
},
{
"id": "u9517tx1x3hxrs5",
"key": "user-e9vck6v3pa951wn",
"displayName": "Rick Cardoza",
"kind": "user",
"policy": "collaborative",
"targetUserId": "e9vck6v3pa951wn",
"deletedAt": null
},
{
"id": "2nlrw1wumhtbc98",
"key": "user-c1uzqhpacn65qt1",
"displayName": "Steve Brewer",
"kind": "user",
"policy": "collaborative",
"targetUserId": null,
"deletedAt": null
},
{
"id": "2ttnn1vcdnniqci",
"key": "user-rrjoxd2wewmlrwy",
"displayName": "David Cardoza",
"kind": "user",
"policy": "collaborative",
"targetUserId": "rrjoxd2wewmlrwy",
"deletedAt": null
},
{
"id": "a3nd0f8tpipjc3e",
"key": "user-sivbh2pwzyew5lb",
"displayName": "Timothy Cardoza",
"kind": "user",
"policy": "collaborative",
"targetUserId": "sivbh2pwzyew5lb",
"deletedAt": null
},
{
"id": "ucj2hrr4htezrzo",
"key": "user-gk3u91dx0r79fo2",
"displayName": "Employee",
"kind": "user",
"policy": "handoff",
"targetUserId": "gk3u91dx0r79fo2",
"deletedAt": null
},
{
"id": "lazcnjz2tsdoi0t",
"key": "user-mchpsyvh0wrcxv6",
"displayName": "Cortney Abrams",
"kind": "user",
"policy": "collaborative",
"targetUserId": "mchpsyvh0wrcxv6",
"deletedAt": null
},
{
"id": "p66mi0tscgw3sri",
"key": "user-ewdxrba73q68kqj",
"displayName": "Joel Snyder",
"kind": "user",
"policy": "collaborative",
"targetUserId": "ewdxrba73q68kqj",
"deletedAt": null
},
{
"id": "asgpvdgthuoyhgx",
"key": "user-s1gwhknxaww38i3",
"displayName": "David Rader",
"kind": "user",
"policy": "collaborative",
"targetUserId": "s1gwhknxaww38i3",
"deletedAt": null
},
{
"id": "5jdlsa7ebt1jd3i",
"key": "user-nc23vrshrg0qtdi",
"displayName": "Ben Poland",
"kind": "user",
"policy": "collaborative",
"targetUserId": "nc23vrshrg0qtdi",
"deletedAt": null
},
{
"id": "r27bfvcvig2n90m",
"key": "user-naztc2sg4b4fgfc",
"displayName": "Mark Huber",
"kind": "user",
"policy": "collaborative",
"targetUserId": "naztc2sg4b4fgfc",
"deletedAt": null
},
{
"id": "il3piz7xfklzw3o",
"key": "testing-bucket",
"displayName": "Testing Bucket",
"kind": "bucket",
"policy": "handoff",
"targetUserId": null,
"deletedAt": null
},
{
"id": "w9zx3zqsi5r8zht",
"key": "vehicle-maintenance",
"displayName": "Vehicle Maintenance",
"kind": "bucket",
"policy": "handoff",
"targetUserId": null,
"deletedAt": null
},
{
"id": "o6n72jlh3s6c42e",
"key": "shop",
"displayName": "Shop",
"kind": "bucket",
"policy": "handoff",
"targetUserId": null,
"deletedAt": null
},
{
"id": "cu4b1epkpwf15zs",
"key": "equipment-maintenance",
"displayName": "Equipment Maintenance",
"kind": "bucket",
"policy": "handoff",
"targetUserId": null,
"deletedAt": null
},
{
"id": "zdqmiqyfjx3z68d",
"key": "development",
"displayName": "Development",
"kind": "bucket",
"policy": "handoff",
"targetUserId": null,
"deletedAt": null
},
{
"id": "pjwskg0eo91n5y6",
"key": "user-i73wk8r7f2evbew",
"displayName": "Eddie",
"kind": "user",
"policy": "collaborative",
"targetUserId": "i73wk8r7f2evbew",
"deletedAt": null
}
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,82 @@
[
{
"email": "it@cardoza.construction",
"id": "oz8sxsyg8hznqa6",
"name": "IT"
},
{
"email": "pbserviceupdate@cardoza.construction",
"id": "x6hu9mgawxzfvk6",
"name": "PBServiceWorker"
},
{
"email": "eewing@cardoza.construction",
"id": "2lmwfhtdwuexmiv",
"name": "Ezekiel Ewing"
},
{
"email": "aewing@cardoza.construction",
"id": "1hbif9dbqng86h9",
"name": "Alfonso Ewing"
},
{
"email": "hmahan@cardoza.construction",
"id": "fdve3i2gop4huym",
"name": "Heidi Mahan"
},
{
"email": "bcardoza@cardoza.construction",
"id": "h9uihmnkwngdu8y",
"name": "Beth Cardoza"
},
{
"email": "rcardoza@cardoza.construction",
"id": "e9vck6v3pa951wn",
"name": "Rick Cardoza"
},
{
"email": "dcardoza@cardoza.construction",
"id": "rrjoxd2wewmlrwy",
"name": "David Cardoza"
},
{
"email": "tcardoza@cardoza.construction",
"id": "sivbh2pwzyew5lb",
"name": "Timothy Cardoza"
},
{
"email": "employee@cardoza.construction",
"id": "gk3u91dx0r79fo2",
"name": "Employee"
},
{
"email": "cabrams@cardoza.construction",
"id": "mchpsyvh0wrcxv6",
"name": "Cortney Abrams"
},
{
"email": "jsnyder@cardoza.construction",
"id": "ewdxrba73q68kqj",
"name": "Joel Snyder"
},
{
"email": "drader@cardoza.construction",
"id": "s1gwhknxaww38i3",
"name": "David Rader"
},
{
"email": "mreds01@aol.com",
"id": "i73wk8r7f2evbew",
"name": "Eddie"
},
{
"email": "bpoland@cardoza.construction",
"id": "nc23vrshrg0qtdi",
"name": "Ben Poland"
},
{
"email": "markhuber66@yahoo.com",
"id": "naztc2sg4b4fgfc",
"name": "Mark Huber"
}
]
@@ -0,0 +1,266 @@
{
"summary": {
"touched": 28,
"personalRefsAdded": 7,
"personalRefsRemoved": 0,
"ambiguousMultiPersonal": 44,
"failures": 0
},
"tasks": [
{
"id": "z6s8kfpek0wowmp",
"title": "4150 Performing Arts - Material Lengths to Bill Mabary - see email",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb",
"nc23vrshrg0qtdi"
]
},
{
"id": "hnn0ruj5kvcj7mi",
"title": "4150 Performing Arts - Acoustical",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb",
"nc23vrshrg0qtdi"
]
},
{
"id": "ij2h8laauwxffyu",
"title": "4118 - Estes new wall CO Awaiting Conf and Sign",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb",
"nc23vrshrg0qtdi"
]
},
{
"id": "yz34jlzc29tq3ve",
"title": "4150 Performing Arts - Framing Shop Drawings",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb",
"nc23vrshrg0qtdi"
]
},
{
"id": "9wu0icmgy8z0dpn",
"title": "4150 Acoustic CO pending response - Performing Arts Center",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb",
"nc23vrshrg0qtdi"
]
},
{
"id": "lq7rzmxv7wtxdx0",
"title": "4118 Estes CO - Windows and Server Room Plywood",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
},
{
"id": "5cs9kcfqkavgy5y",
"title": "Check in with Dad about further contact Kickapoo Ceilings",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb",
"h9uihmnkwngdu8y"
]
},
{
"id": "59apl48j7se00cw",
"title": "reorganize metal & label",
"ownerIdBefore": "s1gwhknxaww38i3",
"ownerIdAfter": "s1gwhknxaww38i3",
"personalUserIds": [
"s1gwhknxaww38i3"
]
},
{
"id": "5vn3zkn7wzaldsk",
"title": "test",
"ownerIdBefore": "gk3u91dx0r79fo2",
"ownerIdAfter": "gk3u91dx0r79fo2",
"personalUserIds": [
"gk3u91dx0r79fo2"
]
},
{
"id": "p2ut35nntf8s5on",
"title": "4841 - Andys HQ DD- Estimate",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
},
{
"id": "kn1nxbmwgz7pyz9",
"title": "Equipment lists",
"ownerIdBefore": "nc23vrshrg0qtdi",
"ownerIdAfter": "nc23vrshrg0qtdi",
"personalUserIds": [
"nc23vrshrg0qtdi"
]
},
{
"id": "yqffg2agv88ggjo",
"title": "4118 - Estes - Lead Time Material Orders",
"ownerIdBefore": "nc23vrshrg0qtdi",
"ownerIdAfter": "nc23vrshrg0qtdi",
"personalUserIds": [
"nc23vrshrg0qtdi"
]
},
{
"id": "n9tb1zd2qovqycz",
"title": "4850 - Intercounty Electric Rolla - Estimate",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
},
{
"id": "5rsy930ttk14kdm",
"title": "4850 - Intercounty Electric Rolla - Estimate (Copy)",
"ownerIdBefore": "e9vck6v3pa951wn",
"ownerIdAfter": "e9vck6v3pa951wn",
"personalUserIds": [
"e9vck6v3pa951wn"
]
},
{
"id": "zfmf0nil83svwan",
"title": "4150 Veregy - Joist lengths",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"nc23vrshrg0qtdi",
"sivbh2pwzyew5lb"
]
},
{
"id": "h2al3o03sfkn40z",
"title": "fix hilit gx-120",
"ownerIdBefore": null,
"ownerIdAfter": null,
"personalUserIds": []
},
{
"id": "m3jk7vi6jhnai3y",
"title": "TEST Tim to Bucket",
"ownerIdBefore": null,
"ownerIdAfter": null,
"personalUserIds": []
},
{
"id": "aq1trlil8wnqgms",
"title": "buffer task",
"ownerIdBefore": "gk3u91dx0r79fo2",
"ownerIdAfter": "gk3u91dx0r79fo2",
"personalUserIds": [
"gk3u91dx0r79fo2"
]
},
{
"id": "zbj1uyj0mvusqnb",
"title": "Door strike",
"ownerIdBefore": "2lmwfhtdwuexmiv",
"ownerIdAfter": "2lmwfhtdwuexmiv",
"personalUserIds": [
"2lmwfhtdwuexmiv"
]
},
{
"id": "9347spthvpgrpsn",
"title": "4845 - Pythian St Ceilings Colin - Estimate",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
},
{
"id": "8xvb21a21k83gd1",
"title": "4150 Veregy Material Order",
"ownerIdBefore": "nc23vrshrg0qtdi",
"ownerIdAfter": "nc23vrshrg0qtdi",
"personalUserIds": [
"nc23vrshrg0qtdi",
"sivbh2pwzyew5lb"
]
},
{
"id": "fzjbasn9r86merg",
"title": "test employee to tim handoff",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
},
{
"id": "w1ce533o8voja8w",
"title": "Task to note via handoff",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
},
{
"id": "wk754kcblrx1pxt",
"title": "Test task employee to note via handoff",
"ownerIdBefore": "gk3u91dx0r79fo2",
"ownerIdAfter": "gk3u91dx0r79fo2",
"personalUserIds": []
},
{
"id": "napobxne0ak37gn",
"title": "Test task employee to note via collab",
"ownerIdBefore": "gk3u91dx0r79fo2",
"ownerIdAfter": "gk3u91dx0r79fo2",
"personalUserIds": [
"gk3u91dx0r79fo2"
]
},
{
"id": "1w3bbufjhoqq3sg",
"title": "Test task employee to tim via collab",
"ownerIdBefore": "gk3u91dx0r79fo2",
"ownerIdAfter": "gk3u91dx0r79fo2",
"personalUserIds": [
"gk3u91dx0r79fo2",
"sivbh2pwzyew5lb"
]
},
{
"id": "6o855v9n1r3147h",
"title": "Test task employee to bucket via collab",
"ownerIdBefore": "gk3u91dx0r79fo2",
"ownerIdAfter": "gk3u91dx0r79fo2",
"personalUserIds": [
"gk3u91dx0r79fo2"
]
},
{
"id": "r9303qb1ejgfoaw",
"title": "4150 Veregy Redo Full Takeoff",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
}
]
}
+3
View File
@@ -8,6 +8,9 @@
"dev:devdata": "vite --host 0.0.0.0 --port 4001 --mode devdata", "dev:devdata": "vite --host 0.0.0.0 --port 4001 --mode devdata",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"build:devdata": "tsc -b && vite build --mode devdata", "build:devdata": "tsc -b && vite build --mode devdata",
"migration:context-ownership:backup": "bun run scripts/context-ownership-migration.ts backup",
"migration:context-ownership:report": "bun run scripts/context-ownership-migration.ts report",
"migration:context-ownership:migrate": "bun run scripts/context-ownership-migration.ts migrate",
"serve": "vite preview --host 0.0.0.0 --port 4000", "serve": "vite preview --host 0.0.0.0 --port 4000",
"serve:devdata": "vite preview --host 0.0.0.0 --port 4002 --mode devdata", "serve:devdata": "vite preview --host 0.0.0.0 --port 4002 --mode devdata",
"---PM2 COMMANDS---": "------------------", "---PM2 COMMANDS---": "------------------",
+596
View File
@@ -0,0 +1,596 @@
import fs from "node:fs/promises";
import path from "node:path";
type SharePolicy = "collaborative" | "handoff";
type ShareKind = "user" | "bucket" | "note";
type ShareContext = {
id: string;
key: string;
displayName: string;
kind: ShareKind;
policy: SharePolicy;
targetUserId?: string | null;
deletedAt?: string | null;
};
type NoteRecord = {
id: string;
title: string;
key: string;
tags: string[];
user: string;
tasks: string[];
};
type TaskShareRef = {
contextId: string;
key: string;
kind: ShareKind;
policy?: SharePolicy;
};
type NoteRef = {
noteId: string;
key: string;
};
type ParsedTag =
| { kind: "share"; raw: string; key: string; display: string }
| { kind: "note"; raw: string; key: string; display: string }
| { kind: "label"; raw: string; key: string; display: string };
type TaskRecord = {
id: string;
title: string;
tags?: string[];
labelTags?: string[];
shareRefs?: TaskShareRef[];
noteRefs?: NoteRef[];
createdBy?: string | null;
user?: string | string[] | null;
};
type Summary = {
touched: number;
personalRefsAdded: number;
personalRefsRemoved: number;
ambiguousMultiPersonal: number;
failures: number;
};
const mode = (process.argv[2] || "report").toLowerCase();
if (!["backup", "report", "migrate"].includes(mode)) {
throw new Error("Usage: bun run scripts/context-ownership-migration.ts [backup|report|migrate]");
}
const dataMode = (process.env.TASGRID_DATA_MODE || "prod").toLowerCase() === "dev" ? "dev" : "prod";
const withDataSuffix = (base: string) => dataMode === "dev" ? `${base}_Dev` : base;
const TASGRID_COLLECTION = withDataSuffix("TasGrid");
const CONTEXTS_COLLECTION = withDataSuffix("TasGrid_Contexts");
const NOTES_COLLECTION = withDataSuffix("TasGrid_Notes");
const NOTE_HANDOFF_POLICY_TAG = "__note_policy_handoff__";
const POCKETBASE_URL = process.env.POCKETBASE_URL || "https://pocketbase.ccllc.pro";
let authToken = "";
const normalizeWhitespace = (value: string) => value.trim().replace(/\s+/g, " ");
const normalizeKey = (value: string) =>
normalizeWhitespace(value)
.toLowerCase()
.replace(/^[@#]+/, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const buildShareTag = (displayName: string) => `@${normalizeWhitespace(displayName)}`;
const buildNoteTag = (value: string) => `#${normalizeWhitespace(value)}`;
const buildUserContextKey = (userId: string) => `user-${userId}`;
const unwrapRelationId = (value: unknown): string | null => {
if (typeof value === "string") return value || null;
if (Array.isArray(value)) {
const first = value[0];
return typeof first === "string" ? first : null;
}
return null;
};
const parseTag = (rawTag: string): ParsedTag => {
const raw = normalizeWhitespace(rawTag);
const prefix = raw[0];
const display = prefix === "@" || prefix === "#" ? raw.slice(1).trim() : raw;
const key = normalizeKey(display);
if (prefix === "@") return { kind: "share", raw, key, display };
if (prefix === "#") return { kind: "note", raw, key, display };
return { kind: "label", raw, key, display };
};
const parseTags = (rawTags: string[] | undefined | null) =>
(rawTags || []).map(parseTag).filter(tag => !!tag.display);
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 DEFAULT_SHARE_POLICY_BY_KIND: Record<ShareKind, SharePolicy> = {
user: "collaborative",
bucket: "handoff",
note: "handoff"
};
const getNoteSharePolicy = (note: Pick<NoteRecord, "tags"> | undefined): SharePolicy =>
note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative";
const relationId = (record: any, field: string) => unwrapRelationId(record?.[field]);
const createOutputDir = async () => {
const outputDir = path.resolve(process.cwd(), "migration-output", `context-ownership-${new Date().toISOString().replace(/[:.]/g, "-")}`);
await fs.mkdir(outputDir, { recursive: true });
return outputDir;
};
const tryAuthEndpoint = async (pathname: string, email: string, password: string) => {
const response = await fetch(`${POCKETBASE_URL}${pathname}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: email, password })
});
if (!response.ok) {
return {
ok: false as const,
status: response.status,
body: await response.text(),
pathname
};
}
return {
ok: true as const,
payload: await response.json(),
pathname
};
};
const auth = async () => {
const email = process.env.PB_ADMIN_EMAIL || process.env.PB_EMAIL;
const password = process.env.PB_ADMIN_PASSWORD || process.env.PB_PASSWORD;
if (!email || !password) {
throw new Error("Set PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD (or PB_EMAIL/PB_PASSWORD) before running this script.");
}
const authAttempts = [
await tryAuthEndpoint("/api/collections/_superusers/auth-with-password", email, password),
await tryAuthEndpoint("/api/admins/auth-with-password", email, password)
];
const success = authAttempts.find(attempt => attempt.ok);
if (!success) {
const details = authAttempts
.map(attempt => `${attempt.pathname}: ${attempt.status} ${attempt.body}`)
.join(" | ");
throw new Error(`PocketBase superuser/admin auth failed. ${details}`);
}
const payload = success.payload;
authToken = payload.token;
};
const request = async (pathname: string, init: RequestInit = {}) => {
const response = await fetch(`${POCKETBASE_URL}${pathname}`, {
...init,
headers: {
"Content-Type": "application/json",
...(authToken ? { Authorization: authToken } : {}),
...(init.headers || {})
}
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
}
if (response.status === 204) {
return null;
}
return response.json();
};
const getFullList = async (collection: string, query: Record<string, string> = {}) => {
const items: any[] = [];
let page = 1;
while (true) {
const search = new URLSearchParams({ perPage: "500", page: String(page), ...query });
const payload = await request(`/api/collections/${collection}/records?${search.toString()}`);
items.push(...(payload.items || []));
if (!payload.totalPages || page >= payload.totalPages) {
break;
}
page += 1;
}
return items;
};
const createRecord = (collection: string, body: Record<string, unknown>) =>
request(`/api/collections/${collection}/records`, {
method: "POST",
body: JSON.stringify(body)
});
const updateRecord = (collection: string, id: string, body: Record<string, unknown>) =>
request(`/api/collections/${collection}/records/${id}`, {
method: "PATCH",
body: JSON.stringify(body)
});
const syncUserContexts = async (users: any[], contexts: ShareContext[]) => {
const nextContexts = [...contexts];
for (const user of users) {
const displayName = user.name || user.email;
const existing = nextContexts.find(context =>
context.kind === "user" &&
context.targetUserId === user.id &&
!context.deletedAt
);
if (!displayName || existing) continue;
const created = await createRecord(CONTEXTS_COLLECTION, {
key: buildUserContextKey(user.id),
displayName,
kind: "user",
policy: "collaborative",
targetUserId: user.id
});
nextContexts.push({
id: created.id,
key: created.key,
displayName: created.displayName,
kind: created.kind,
policy: "collaborative",
targetUserId: relationId(created, "targetUserId"),
deletedAt: created.deletedAt || null
});
}
return nextContexts;
};
const buildNoteLinkMap = (notes: NoteRecord[]) => {
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 main = async () => {
await auth();
const [users, rawContexts, rawNotes, rawTasks] = await Promise.all([
getFullList("users", { fields: "id,name,email" }),
getFullList(CONTEXTS_COLLECTION),
getFullList(NOTES_COLLECTION),
getFullList(TASGRID_COLLECTION)
]);
const contexts = mode === "migrate"
? await syncUserContexts(users, rawContexts.map((record: any) => ({
id: record.id,
key: record.key,
displayName: record.displayName,
kind: record.kind || "bucket",
policy: (record.policy || (record.kind === "user" ? "collaborative" : "handoff")) as SharePolicy,
targetUserId: relationId(record, "targetUserId"),
deletedAt: record.deletedAt || null
})))
: rawContexts.map((record: any) => ({
id: record.id,
key: record.key,
displayName: record.displayName,
kind: record.kind || "bucket",
policy: (record.policy || (record.kind === "user" ? "collaborative" : "handoff")) as SharePolicy,
targetUserId: relationId(record, "targetUserId"),
deletedAt: record.deletedAt || null
}));
const notes = rawNotes.map((record: any) => ({
id: record.id,
title: record.title,
key: record.key || normalizeKey(record.title || record.id),
tags: Array.isArray(record.tags) ? record.tags.filter(Boolean) : [],
user: relationId(record, "user") || "",
tasks: Array.isArray(record.tasks) ? record.tasks.filter(Boolean) : []
})) satisfies NoteRecord[];
const contextById = new Map(contexts.map(context => [context.id, context]));
const ownerContextByUserId = new Map(
contexts
.filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt)
.map(context => [context.targetUserId as string, context] as const)
);
const noteById = new Map(notes.map(note => [note.id, note]));
const noteByKey = new Map(notes.map(note => [note.key, note]));
const noteLinksByTaskId = buildNoteLinkMap(notes);
const getContextByRef = (ref: TaskShareRef) => {
if (ref.kind === "note") {
const note = noteById.get(ref.contextId) || noteByKey.get(ref.key);
return note
? {
id: note.id,
key: note.key,
displayName: note.key || note.title,
kind: "note" as const,
policy: getNoteSharePolicy(note),
targetUserId: note.user
}
: null;
}
return contextById.get(ref.contextId)
|| contexts.find(context => !context.deletedAt && context.key === ref.key && context.kind === ref.kind)
|| contexts.find(context => !context.deletedAt && normalizeKey(context.displayName) === ref.key)
|| null;
};
const getTaskSharePolicy = (ref: TaskShareRef): SharePolicy => {
if (ref.kind === "note") {
if (ref.policy) return ref.policy;
const note = noteById.get(ref.contextId) || noteByKey.get(ref.key);
return getNoteSharePolicy(note);
}
const context = getContextByRef(ref);
if (ref.policy) return ref.policy;
return context?.policy || DEFAULT_SHARE_POLICY_BY_KIND[ref.kind];
};
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 = (refs: TaskShareRef[]) =>
refs.filter(ref => ref.kind === "user" && !!getPersonalContextUserIdFromRef(ref));
const resolveMirroredOwnerId = (currentOwnerId: string | null, refs: TaskShareRef[], preferredOwnerId?: string | null) => {
const personalUserIds = [...new Set(
refs
.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 buildTaskTags = (labelTags: string[], shareRefs: TaskShareRef[], noteRefs: NoteRef[], favoriteTag?: string) => {
const tags = [
...labelTags,
...shareRefs.map(ref => {
const context = getContextByRef(ref);
if (ref.kind === "note") return buildNoteTag(context?.key || ref.key);
if (context?.displayName) return buildShareTag(context.displayName);
if (ref.kind === "user" && ref.key.startsWith("user-")) return null;
return buildShareTag(ref.key);
}),
...noteRefs.map(ref => {
const note = noteById.get(ref.noteId) || noteByKey.get(ref.key);
return buildNoteTag(note?.key || ref.key);
})
];
if (favoriteTag && !tags.includes(favoriteTag)) tags.push(favoriteTag);
return [...new Set(tags.filter((value): value is string => !!value))];
};
const summary: Summary = {
touched: 0,
personalRefsAdded: 0,
personalRefsRemoved: 0,
ambiguousMultiPersonal: 0,
failures: 0
};
const reportRows: any[] = [];
for (const record of rawTasks as TaskRecord[]) {
if ((record.tags || []).includes("__template__")) continue;
try {
const legacyTags = record.tags || [];
const parsedTags = parseTags(legacyTags).filter(tag => tag.raw !== "__template__");
const shareRefs = Array.isArray(record.shareRefs)
? record.shareRefs.map(ref => ({
contextId: ref.contextId,
key: ref.key,
kind: ref.kind,
policy: ref.policy
}))
: parsedTags
.filter((tag): tag is Extract<(typeof parsedTags)[number], { kind: "share" | "note" }> => tag.kind === "share" || tag.kind === "note")
.map(tag => {
if (tag.kind === "note") {
const note = noteByKey.get(tag.key);
return {
contextId: note?.id || tag.key,
key: tag.key,
kind: "note" as const
};
}
const context = contexts.find(existing =>
!existing.deletedAt && (
existing.key === tag.key ||
normalizeKey(existing.displayName) === tag.key ||
buildShareTag(existing.displayName).toLowerCase() === tag.raw.toLowerCase()
)
);
return {
contextId: context?.id || tag.key,
key: tag.key,
kind: (context?.kind || "bucket") as "user" | "bucket",
policy: context?.policy
};
});
const noteRefs = dedupeNoteRefs([
...(Array.isArray(record.noteRefs)
? record.noteRefs.map(ref => ({ noteId: ref.noteId, key: ref.key }))
: parsedTags
.filter(tag => tag.kind === "note")
.map(tag => {
const note = noteByKey.get(tag.key);
return {
noteId: note?.id || tag.key,
key: tag.key
};
})),
...(noteLinksByTaskId.get(record.id) || [])
]);
let nextShareRefs = dedupeShareRefs([
...shareRefs,
...noteRefs.map(ref => ({
contextId: ref.noteId,
key: ref.key,
kind: "note" as const,
policy: shareRefs.find(existing => existing.kind === "note" && (existing.contextId === ref.noteId || existing.key === ref.key))?.policy
}))
]);
const ownerId = unwrapRelationId(record.user);
const ownerContext = ownerId ? ownerContextByUserId.get(ownerId) : undefined;
const shouldRemoveOwnerPersonalRef = !!ownerId &&
getTaskPersonalRefs(nextShareRefs).length === 0 &&
nextShareRefs.some(ref => ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff");
if (ownerContext) {
const matchesOwnerContext = (ref: TaskShareRef) =>
ref.kind === "user" && (
ref.contextId === ownerContext.id ||
ref.key === ownerContext.key ||
normalizeKey(ownerContext.displayName) === ref.key
);
if (shouldRemoveOwnerPersonalRef) {
const before = nextShareRefs.length;
nextShareRefs = nextShareRefs.filter(ref => !matchesOwnerContext(ref));
if (before !== nextShareRefs.length) {
summary.personalRefsRemoved += before - nextShareRefs.length;
}
} else if (!nextShareRefs.some(matchesOwnerContext)) {
nextShareRefs = [
{
contextId: ownerContext.id,
key: ownerContext.key,
kind: "user" as const,
policy: ownerContext.policy
},
...nextShareRefs
];
summary.personalRefsAdded += 1;
}
}
nextShareRefs = dedupeShareRefs(nextShareRefs);
const labelTags = Array.isArray(record.labelTags)
? record.labelTags
: parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display);
const favoriteTag = legacyTags.find(tag => tag.endsWith("_favorite__"));
const nextTags = buildTaskTags(labelTags, nextShareRefs, noteRefs, favoriteTag);
const nextOwnerId = resolveMirroredOwnerId(ownerId, nextShareRefs, ownerId);
const personalUserIds = [...new Set(nextShareRefs.map(ref => getPersonalContextUserIdFromRef(ref)).filter((value): value is string => !!value))];
if (personalUserIds.length > 1) {
summary.ambiguousMultiPersonal += 1;
}
const changed =
JSON.stringify(nextShareRefs) !== JSON.stringify(record.shareRefs || []) ||
JSON.stringify(noteRefs) !== JSON.stringify(record.noteRefs || []) ||
JSON.stringify(nextTags) !== JSON.stringify(record.tags || []) ||
nextOwnerId !== ownerId;
if (changed) {
summary.touched += 1;
reportRows.push({
id: record.id,
title: record.title,
ownerIdBefore: ownerId,
ownerIdAfter: nextOwnerId,
personalUserIds
});
if (mode === "migrate") {
await updateRecord(TASGRID_COLLECTION, record.id, {
user: nextOwnerId,
tags: nextTags,
labelTags,
shareRefs: nextShareRefs,
noteRefs
});
}
}
} catch (error) {
summary.failures += 1;
reportRows.push({
id: record.id,
title: record.title,
error: error instanceof Error ? error.message : String(error)
});
}
}
if (mode === "backup") {
const outputDir = await createOutputDir();
await Promise.all([
fs.writeFile(path.join(outputDir, "users.json"), JSON.stringify(users, null, 2)),
fs.writeFile(path.join(outputDir, "contexts.json"), JSON.stringify(contexts, null, 2)),
fs.writeFile(path.join(outputDir, "notes.json"), JSON.stringify(rawNotes, null, 2)),
fs.writeFile(path.join(outputDir, "tasks.json"), JSON.stringify(rawTasks, null, 2))
]);
console.log(JSON.stringify({ mode, dataMode, outputDir, counts: { users: users.length, contexts: contexts.length, notes: rawNotes.length, tasks: rawTasks.length } }, null, 2));
return;
}
if (mode === "report") {
const outputDir = await createOutputDir();
await fs.writeFile(path.join(outputDir, "context-ownership-report.json"), JSON.stringify({ summary, tasks: reportRows }, null, 2));
console.log(JSON.stringify({ mode, dataMode, outputDir, summary }, null, 2));
return;
}
console.log(JSON.stringify({ mode, dataMode, summary }, null, 2));
};
main().catch((error) => {
console.error(error);
process.exit(1);
});
+200 -108
View File
@@ -217,8 +217,9 @@ export const startContextPolling = () => {
try { try {
// Fetch only recent updates (last 30s) to minimize payload // Fetch only recent updates (last 30s) to minimize payload
const timeStr = new Date(Date.now() - 30000).toISOString(); const timeStr = new Date(Date.now() - 30000).toISOString();
const contextFilter = buildPersonalAccessFilter([personalOrUserId]);
const records = await pb.collection(TASGRID_COLLECTION).getFullList({ const records = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `${relationFilter("user", personalOrUserId)} && updated >= "${timeStr}"`, filter: `${contextFilter} && updated >= "${timeStr}"`,
sort: '-updated', sort: '-updated',
fields: getTaskListFields(), fields: getTaskListFields(),
requestKey: 'context-poll' requestKey: 'context-poll'
@@ -286,6 +287,7 @@ interface ScopedTaskgridPrefs {
collapsedUntilUpdatedTasks?: Record<string, string>; collapsedUntilUpdatedTasks?: Record<string, string>;
migratedStructuredTagsV2?: boolean; migratedStructuredTagsV2?: boolean;
migratedPersonalContextRefsV1?: boolean; migratedPersonalContextRefsV1?: boolean;
migratedContextOwnershipV2?: boolean;
lastCompletedTaskCleanupDate?: string; // YYYY-MM-DD, per user/environment 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"; note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative";
export const getTaskSharePolicy = (ref: Pick<TaskShareRef, "kind" | "key" | "policy" | "contextId">): "collaborative" | "handoff" => { export const getTaskSharePolicy = (ref: Pick<TaskShareRef, "kind" | "key" | "policy" | "contextId">): "collaborative" | "handoff" => {
if (ref.policy) return ref.policy;
if (ref.kind === "note") { if (ref.kind === "note") {
if (ref.policy) return ref.policy;
const note = store.notes.find(existing => existing.id === ref.contextId || existing.key === ref.key); const note = store.notes.find(existing => existing.id === ref.contextId || existing.key === ref.key);
return getNoteSharePolicy(note); return getNoteSharePolicy(note);
} }
const context = getContextByRef(ref as TaskShareRef); const context = getContextByRef(ref as TaskShareRef);
if (ref.policy) return ref.policy;
if (context?.policy) return context.policy; if (context?.policy) return context.policy;
return DEFAULT_SHARE_POLICY_BY_KIND[ref.kind]; return DEFAULT_SHARE_POLICY_BY_KIND[ref.kind];
@@ -868,6 +870,106 @@ const getPersonalContext = (userId = pb.authStore.model?.id) =>
) || null ) || null
: 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) => { export const isCurrentUserContextTag = (tag: string) => {
const normalizedTag = tag.trim().toLowerCase(); const normalizedTag = tag.trim().toLowerCase();
if (!normalizedTag.startsWith("@")) return false; if (!normalizedTag.startsWith("@")) return false;
@@ -937,69 +1039,34 @@ export const matchesFilter = (task: Task) => {
const activeShareContexts = task.shareRefs const activeShareContexts = task.shareRefs
.map(ref => getContextByRef(ref)) .map(ref => getContextByRef(ref))
.filter((context): context is ShareContext => !!context && !context.deletedAt); .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 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" || ( const isPersonalView = ctx === "mine" || (
typeof ctx === "object" && typeof ctx === "object" &&
"bucketId" in ctx && "bucketId" in ctx &&
!!personalContext && !!personalContext &&
ctx.bucketId === personalContext.id 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 // 0. Context Filter
if (isPersonalView) { if (isPersonalView) {
if (hasHandoffContext) return false; if (!currentUserId || !taskHasPersonalContextAccess(task, currentUserId)) return false;
const isOwner = task.ownerId === currentUserId;
if (!isOwner && !collaborativeUserShare && !inPersonalContext) return false;
} else if (typeof ctx === 'object' && 'bucketId' in ctx) { } else if (typeof ctx === 'object' && 'bucketId' in ctx) {
const activeBucketContext = store.contexts.find(context => context.id === (ctx as { bucketId: string }).bucketId); const activeBucketContext = store.contexts.find(context => context.id === (ctx as { bucketId: string }).bucketId);
const activeBucketKey = activeBucketContext?.key || normalizeContextKey((ctx as { name: string }).name); const activeBucketKey = activeBucketContext?.key || normalizeContextKey((ctx as { name: string }).name);
const inCurrentBucket = rawBucketRefs.some(ref => const inCurrentBucket = activeBucketContext?.kind === "user"
ref.contextId === (ctx as { bucketId: string }).bucketId || ? !!activeBucketContext.targetUserId && taskHasPersonalContextAccess(task, activeBucketContext.targetUserId)
ref.key === activeBucketKey : rawBucketRefs.some(ref =>
) || bucketRefs.some(context => context.id === (ctx as { bucketId: string }).bucketId); ref.contextId === (ctx as { bucketId: string }).bucketId ||
ref.key === activeBucketKey
) || bucketRefs.some(context => context.id === (ctx as { bucketId: string }).bucketId);
if (!inCurrentBucket) return false; if (!inCurrentBucket) return false;
} else { } else {
const targetUserId = (ctx as { userId: string }).userId; const targetUserId = (ctx as { userId: string }).userId;
const targetPersonalContext = getPersonalContext(targetUserId); if (!taskHasPersonalContextAccess(task, 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) {
return false; return false;
} }
} }
if (isPersonalView && inAnyBucket && !inPinnedBucket && task.ownerId !== currentUserId) return false;
// Hide templates from all regular views // Hide templates from all regular views
if (task.tags?.includes("__template__")) return false; if (task.tags?.includes("__template__")) return false;
@@ -1046,7 +1113,7 @@ export const matchesFilter = (task: Task) => {
} }
// Owned By Me // Owned By Me
if (f.ownedByMe && task.ownerId !== currentUserId) { if (f.ownedByMe && (!currentUserId || !taskHasPersonalContextAccess(task, currentUserId))) {
return false; return false;
} }
@@ -1107,7 +1174,7 @@ createRoot(() => {
// Calculate Top 20 Tasks // Calculate Top 20 Tasks
const incompleteTasks = store.tasks.filter(t => const incompleteTasks = store.tasks.filter(t =>
!t.completed && !t.completed &&
t.ownerId === pb.authStore.model?.id && taskHasPersonalContextAccess(t, pb.authStore.model?.id || "", true) &&
!t.tags?.includes("__template__") && !t.tags?.includes("__template__") &&
!t.deletedAt !t.deletedAt
); );
@@ -1306,7 +1373,9 @@ const buildTaskMigrationPayload = (
const ownerContext = task.ownerId ? ownerContextByUserId.get(task.ownerId) : undefined; const ownerContext = task.ownerId ? ownerContextByUserId.get(task.ownerId) : undefined;
let nextShareRefs = dedupeShareRefs(task.shareRefs); let nextShareRefs = dedupeShareRefs(task.shareRefs);
let nextNoteRefs = dedupeNoteRefs([...task.noteRefs, ...linkedNoteRefs]); 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) { if (ownerContext) {
const matchesOwnerContext = (ref: TaskShareRef) => const matchesOwnerContext = (ref: TaskShareRef) =>
@@ -1316,7 +1385,7 @@ const buildTaskMigrationPayload = (
normalizeContextKey(ownerContext.displayName) === ref.key normalizeContextKey(ownerContext.displayName) === ref.key
); );
if (hasHandoffContext) { if (shouldHideFromLegacyOwner) {
nextShareRefs = nextShareRefs.filter(ref => !matchesOwnerContext(ref)); nextShareRefs = nextShareRefs.filter(ref => !matchesOwnerContext(ref));
} else if (!nextShareRefs.some(matchesOwnerContext)) { } else if (!nextShareRefs.some(matchesOwnerContext)) {
nextShareRefs = [ nextShareRefs = [
@@ -1332,12 +1401,18 @@ const buildTaskMigrationPayload = (
} }
nextShareRefs = dedupeShareRefs(nextShareRefs); nextShareRefs = dedupeShareRefs(nextShareRefs);
const mirroredOwnerId = resolveMirroredOwnerId(
task.ownerId || fallbackUserId || pb.authStore.model?.id || null,
nextShareRefs,
task.ownerId || fallbackUserId || null
);
return { return {
tags: buildTaskTags(task.labelTags, nextShareRefs, nextNoteRefs, favoriteTag), tags: buildTaskTags(task.labelTags, nextShareRefs, nextNoteRefs, favoriteTag),
labelTags: task.labelTags, labelTags: task.labelTags,
shareRefs: nextShareRefs, shareRefs: nextShareRefs,
noteRefs: nextNoteRefs, noteRefs: nextNoteRefs,
ownerId: mirroredOwnerId,
createdBy: task.createdBy || task.ownerId || fallbackUserId || pb.authStore.model?.id || "" 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 // Helper to check if a task should be visible based on ownership, shares, or rules
const shouldTaskBeVisible = (task: any, currentUserId: string): boolean => { const shouldTaskBeVisible = (task: any, currentUserId: string): boolean => {
const mappedTask = "shareRefs" in task ? task as Task : mapRecordToTask(task); const mappedTask = "shareRefs" in task ? task as Task : mapRecordToTask(task);
if (mappedTask.ownerId === currentUserId) return true; if (taskHasPersonalContextAccess(mappedTask, currentUserId)) return true;
if (store.supervisedContexts.some(context => context.ownerUserId === mappedTask.ownerId)) 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 => { return mappedTask.shareRefs.some(ref => {
if (ref.kind !== "user" || getTaskSharePolicy(ref) !== "collaborative") return false; if (ref.kind !== "user" || getTaskSharePolicy(ref) !== "collaborative") return false;
@@ -1879,7 +1966,7 @@ const syncUserContexts = async () => {
key: buildUserContextKey(user.id), key: buildUserContextKey(user.id),
displayName, displayName,
kind: "user", kind: "user",
policy: "handoff", policy: "collaborative",
targetUserId: user.id targetUserId: user.id
}, { requestKey: null }).catch(() => null); }, { requestKey: null }).catch(() => null);
if (created) { 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; const currentUserId = pb.authStore.model?.id;
if (!currentUserId) return; if (!currentUserId) return;
@@ -1942,19 +2029,23 @@ const runPersonalContextMigration = async (force = false) => {
const rawPrefs = userRec.Taskgrid_pref || {}; const rawPrefs = userRec.Taskgrid_pref || {};
const prefs = readScopedPrefs(rawPrefs); const prefs = readScopedPrefs(rawPrefs);
if (!force && prefs.migratedPersonalContextRefsV1) { if (!force && prefs.migratedContextOwnershipV2) {
return; return;
} }
const contexts = store.contexts.length > 0 await syncUserContexts();
? store.contexts const contexts = store.contexts.length > 0 ? store.contexts : await loadContextsIntoStore();
: await loadContextsIntoStore();
const ownerContextByUserId = new Map( const ownerContextByUserId = new Map(
contexts contexts
.filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt) .filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt)
.map(context => [context.targetUserId as string, context]) .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({ const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
fields: getTaskListFields(), fields: getTaskListFields(),
@@ -1976,19 +2067,27 @@ const runPersonalContextMigration = async (force = false) => {
const sameShareRefs = JSON.stringify(payload.shareRefs) === JSON.stringify(task.shareRefs); const sameShareRefs = JSON.stringify(payload.shareRefs) === JSON.stringify(task.shareRefs);
const sameNoteRefs = JSON.stringify(payload.noteRefs) === JSON.stringify(task.noteRefs); const sameNoteRefs = JSON.stringify(payload.noteRefs) === JSON.stringify(task.noteRefs);
const sameTags = JSON.stringify(payload.tags) === JSON.stringify(task.tags); 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, { await pb.collection('users').update(currentUserId, {
Taskgrid_pref: writeScopedPrefs(rawPrefs, { Taskgrid_pref: writeScopedPrefs(rawPrefs, {
...prefs, ...prefs,
migratedPersonalContextRefsV1: true migratedPersonalContextRefsV1: true,
migratedContextOwnershipV2: true
}) })
}, { requestKey: null }); }, { requestKey: null });
} catch (err) { } 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 toast.promise((async () => {
await runLegacyTagMigration(true); await runLegacyTagMigration(true);
await runPersonalContextMigration(true); await runContextOwnershipMigration(true);
await loadContextsIntoStore(); await loadContextsIntoStore();
await syncSupervisedContexts(); await syncSupervisedContexts();
return "Dev data migration completed."; return "Dev data migration completed.";
@@ -2027,7 +2126,7 @@ export const runProdDataMigration = async () => {
await toast.promise((async () => { await toast.promise((async () => {
await runLegacyTagMigration(true); await runLegacyTagMigration(true);
await runPersonalContextMigration(true); await runContextOwnershipMigration(true);
await loadContextsIntoStore(); await loadContextsIntoStore();
await syncSupervisedContexts(); await syncSupervisedContexts();
return "Production data migration completed."; 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)); res.sort((a, b) => cachedQuickload.indexOf(a.id) - cachedQuickload.indexOf(b.id));
focusRecords = { items: res }; focusRecords = { items: res };
} else { } else {
const personalAccessFilter = buildPersonalAccessFilter([currentUserId]);
focusRecords = await pb.collection(TASGRID_COLLECTION).getList(1, 30, { focusRecords = await pb.collection(TASGRID_COLLECTION).getList(1, 30, {
filter: `${relationFilter("user", currentUserId)} && completed = false && deletedAt = ""`, filter: `${personalAccessFilter} && completed = false && deletedAt = ""`,
sort: '-priority,dueDate', sort: '-priority,dueDate',
requestKey: null requestKey: null
}); });
@@ -2206,18 +2306,13 @@ export const initStore = async () => {
const subscribedBucketTags = getSubscribedBucketContexts() const subscribedBucketTags = getSubscribedBucketContexts()
.map(context => buildShareTag(context.displayName)); .map(context => buildShareTag(context.displayName));
const supervisedUserIds = store.supervisedContexts.map(context => context.ownerUserId); const supervisedUserIds = store.supervisedContexts.map(context => context.ownerUserId);
const collaborativeKeys = store.contexts const currentUserAccessFilter = buildPersonalAccessFilter(currentUserId ? [currentUserId] : []);
.filter(context => const supervisedAccessFilter = buildPersonalAccessFilter(supervisedUserIds);
!context.deletedAt &&
context.kind === "user" &&
context.targetUserId === currentUserId
)
.map(context => buildShareTag(context.displayName));
// 2a. Fetch ALL Incomplete Tasks (Metadata Only) to ensure availability // 2a. Fetch ALL Incomplete Tasks (Metadata Only) to ensure availability
const incompletePromises = [ const incompletePromises = [
pb.collection(TASGRID_COLLECTION).getFullList({ pb.collection(TASGRID_COLLECTION).getFullList({
filter: `${relationFilter("user", currentUserId)} && completed = false`, filter: `${currentUserAccessFilter} && completed = false`,
sort: '-created', sort: '-created',
fields: getTaskListFields(), fields: getTaskListFields(),
requestKey: null requestKey: null
@@ -2228,14 +2323,8 @@ export const initStore = async () => {
fields: getTaskListFields(), fields: getTaskListFields(),
requestKey: null requestKey: null
}).catch(() => []) : Promise.resolve([]), }).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({ (supervisedUserIds.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({
filter: `(${supervisedUserIds.map(id => relationFilter("user", id)).join(' || ')}) && completed = false`, filter: `${supervisedAccessFilter} && completed = false`,
sort: '-created', sort: '-created',
fields: getTaskListFields(), fields: getTaskListFields(),
requestKey: null requestKey: null
@@ -2302,8 +2391,9 @@ export const initStore = async () => {
if (remainingSlots > 0) { if (remainingSlots > 0) {
try { try {
const completedAccessFilter = buildPersonalAccessFilter(currentUserId ? [currentUserId] : []);
const recentCompleted = await pb.collection(TASGRID_COLLECTION).getList(1, remainingSlots, { const recentCompleted = await pb.collection(TASGRID_COLLECTION).getList(1, remainingSlots, {
filter: `${relationFilter("user", currentUserId)} && completed = true`, filter: `${completedAccessFilter} && completed = true`,
sort: '-updated', sort: '-updated',
fields: getTaskListFields(), fields: getTaskListFields(),
requestKey: null requestKey: null
@@ -2346,7 +2436,7 @@ export const initStore = async () => {
// Run one-time migration automatically only in dev data mode. // Run one-time migration automatically only in dev data mode.
if (TASGRID_IS_DEV_DATA) { if (TASGRID_IS_DEV_DATA) {
await runLegacyTagMigration(); await runLegacyTagMigration();
await runPersonalContextMigration(); await runContextOwnershipMigration();
} }
// Separate Templates load (less critical) // 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) // Apply Handoff Logic (e.g. transfer ownership if tagging for another user)
// We treat the entire new task as "updates" to check against itself // We treat the entire new task as "updates" to check against itself
const handoffUpdates = checkForHandoffs(initialTask, initialTask); 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 // Optimistic UI update
setStore("tasks", (t) => [newTask, ...t]); 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 checkForHandoffs = (task: Task, updates: Partial<Task>): Partial<Task> => {
const currentUserId = pb.authStore.model?.id; 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 refs = updates.shareRefs || task.shareRefs;
const senderPersonalContext = getPersonalContext(currentUserId); const senderPersonalContext = getPersonalContext(currentUserId);
@@ -2724,6 +2820,12 @@ const checkForHandoffs = (task: Task, updates: Partial<Task>): Partial<Task> =>
for (const ref of refs) { for (const ref of refs) {
const context = getContextByRef(ref); const context = getContextByRef(ref);
if (senderPersonalContext && ref.kind === "user" && (
ref.contextId === senderPersonalContext.id ||
ref.key === senderPersonalContext.key
)) {
continue;
}
if (!context || getTaskSharePolicy(ref) !== "handoff") continue; if (!context || getTaskSharePolicy(ref) !== "handoff") continue;
const nextShareRefs = stripSenderPersonalContext(refs); const nextShareRefs = stripSenderPersonalContext(refs);
const labelTags = updates.labelTags || task.labelTags; const labelTags = updates.labelTags || task.labelTags;
@@ -2801,6 +2903,13 @@ export const updateTask = async (id: string, updates: Partial<Task>) => {
); );
} }
finalUpdates = checkForHandoffs(currentTask, finalUpdates); 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 // Optimistic update with timestamp to prevent stale realtime events
@@ -3141,16 +3250,11 @@ export const loadAllHistory = async () => {
const bucketTags = getSubscribedBucketContexts() const bucketTags = getSubscribedBucketContexts()
.map(context => buildShareTag(context.displayName)); .map(context => buildShareTag(context.displayName));
const supervisedUserIds = store.supervisedContexts.map(context => context.ownerUserId); const supervisedUserIds = store.supervisedContexts.map(context => context.ownerUserId);
const collaborativeTags = store.contexts const userAccessFilter = buildPersonalAccessFilter([userId]);
.filter(context => const supervisedAccessFilter = buildPersonalAccessFilter(supervisedUserIds);
!context.deletedAt &&
context.kind === "user" &&
context.targetUserId === userId
)
.map(context => buildShareTag(context.displayName));
const promises = [ 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) { if (bucketTags.length > 0) {
@@ -3162,18 +3266,9 @@ export const loadAllHistory = async () => {
}).catch(() => [])); }).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) { if (supervisedUserIds.length > 0) {
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({ promises.push(pb.collection(TASGRID_COLLECTION).getFullList({
filter: supervisedUserIds.map(id => relationFilter("user", id)).join(' || '), filter: supervisedAccessFilter,
sort: '-created', sort: '-created',
fields: getTaskListFields(), fields: getTaskListFields(),
requestKey: null requestKey: null
@@ -3406,10 +3501,7 @@ export const loadTasksForOwner = async (userId: string) => {
if (!pb.authStore.isValid || !userId) return; if (!pb.authStore.isValid || !userId) return;
try { try {
const targetPersonalContext = getPersonalContext(userId); const filter = buildPersonalAccessFilter([userId]);
const filter = targetPersonalContext
? `tags ~ "${buildShareTag(targetPersonalContext.displayName)}"`
: relationFilter("user", userId);
const records = await pb.collection(TASGRID_COLLECTION).getFullList({ const records = await pb.collection(TASGRID_COLLECTION).getFullList({
filter, filter,
sort: '-created', sort: '-created',