user-context migration
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
+266
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,6 +8,9 @@
|
||||
"dev:devdata": "vite --host 0.0.0.0 --port 4001 --mode devdata",
|
||||
"build": "tsc -b && vite build",
|
||||
"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:devdata": "vite preview --host 0.0.0.0 --port 4002 --mode devdata",
|
||||
"---PM2 COMMANDS---": "------------------",
|
||||
|
||||
@@ -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
@@ -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