Files
TasGrid/src/store/contexts.ts
T

865 lines
32 KiB
TypeScript

import { reconcile } from "solid-js/store";
import { toast } from "solid-sonner";
import { pb } from "@/lib/pocketbase";
import { CONTEXTS_COLLECTION, NOTES_COLLECTION, TASGRID_COLLECTION } from "@/lib/constants";
import { buildNoteTag, buildShareTag, normalizeContextKey, normalizeNoteKey, parseTags } from "@/lib/tags";
import {
activeNoteId,
currentTaskContext,
type Bucket,
type Note,
type NoteRef,
readScopedPrefs,
type ShareContext,
type ShareRule,
setStore,
store,
type TagDefinition,
type Task,
type TaskContext,
type TaskShareRef,
unwrapRelationId,
unwrapRelationIds,
writeScopedPrefs
} from "./state";
export const relationFilter = (field: string, id: string | undefined | null) =>
id ? `(${field} = "${id}" || ${field} ?= "${id}")` : "false";
export const buildUserContextKey = (userId: string) => `user-${userId}`;
const DEFAULT_LABEL_THEME: "light" | "dark" = "dark";
const mapContextToBucket = (context: ShareContext): Bucket => ({
id: context.id,
name: context.displayName,
color: context.color || "#64748b",
description: context.description,
policy: context.policy,
key: context.key,
deletedAt: context.deletedAt ?? null
});
const deriveBuckets = (contexts: ShareContext[]) =>
contexts
.filter(context => context.kind === "bucket" && !context.deletedAt)
.sort((a, b) => a.displayName.localeCompare(b.displayName))
.map(mapContextToBucket);
const deriveSystemTagDefinitions = (contexts: ShareContext[]): TagDefinition[] =>
contexts
.filter(context => !context.deletedAt)
.map(context => ({
id: context.id,
name: buildShareTag(context.displayName),
value: 5,
color: context.color,
theme: DEFAULT_LABEL_THEME,
isUser: context.kind === "user",
isBucket: context.kind === "bucket"
}));
const findContextForShareTag = (tag: { key: string; raw: string }) =>
store.contexts.find(context => !context.deletedAt && context.key === tag.key) ||
store.contexts.find(context => !context.deletedAt && context.kind === "user" && normalizeContextKey(context.displayName) === tag.key) ||
store.contexts.find(context => !context.deletedAt && normalizeContextKey(context.displayName) === tag.key) ||
store.contexts.find(context => !context.deletedAt && buildShareTag(context.displayName).toLowerCase() === tag.raw.toLowerCase()) ||
null;
const buildNoteContext = (note: Note): ShareContext => ({
id: note.id,
key: note.key,
displayName: note.key || note.title,
kind: "note",
policy: getNoteSharePolicy(note),
targetUserId: note.user,
deletedAt: note.deletedAt ?? null
});
export const getContextByRef = (ref: TaskShareRef) =>
(ref.kind === "note"
? (() => {
const note = store.notes.find(existing => !existing.deletedAt && (existing.id === ref.contextId || existing.key === ref.key));
return note ? buildNoteContext(note) : null;
})()
: null) ||
store.contexts.find(context => !context.deletedAt && context.id === ref.contextId) ||
store.contexts.find(context => !context.deletedAt && context.key === ref.key && context.kind === ref.kind) ||
store.contexts.find(context => !context.deletedAt && context.kind === ref.kind && normalizeContextKey(context.displayName) === ref.key) ||
store.contexts.find(context => !context.deletedAt && normalizeContextKey(context.displayName) === ref.key) ||
null;
export const getNoteByRef = (ref: NoteRef) =>
store.notes.find(note => !note.deletedAt && note.id === ref.noteId) ||
store.notes.find(note => !note.deletedAt && note.key === ref.key);
const DEFAULT_SHARE_POLICY_BY_KIND: Record<TaskShareRef["kind"], "collaborative" | "handoff"> = {
user: "collaborative",
bucket: "handoff",
note: "handoff"
};
const NOTE_HANDOFF_POLICY_TAG = "__note_policy_handoff__";
export const getNoteSharePolicy = (note: Pick<Note, "tags"> | null | undefined): "collaborative" | "handoff" =>
note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative";
export const getTaskSharePolicy = (ref: Pick<TaskShareRef, "kind" | "key" | "policy" | "contextId">): "collaborative" | "handoff" => {
if (ref.kind === "note") {
if (ref.policy) return ref.policy;
const note = store.notes.find(existing => !existing.deletedAt && (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];
};
export 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 = getNoteByRef(ref);
return buildNoteTag(note?.key || ref.key);
})
];
if (favoriteTag && !tags.includes(favoriteTag)) {
tags.push(favoriteTag);
}
return [...new Set(tags.filter((tag): tag is string => !!tag))];
};
export 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;
});
};
export const buildTaskShareRef = (
parsedTag: Extract<ReturnType<typeof parseTags>[number], { kind: "share" | "note" }>,
existingRefs: TaskShareRef[] = [],
shareModeOverrides: Record<string, "collaborative" | "handoff"> = {}
): TaskShareRef => {
if (parsedTag.kind === "note") {
const note = store.notes.find(existing => !existing.deletedAt && existing.key === parsedTag.key);
const nextRefBase = {
contextId: note?.id || parsedTag.key,
key: parsedTag.key,
kind: "note" as const
};
const existing = existingRefs.find(ref => ref.kind === "note" && (ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key));
return {
...nextRefBase,
policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(nextRefBase)
};
}
const context = findContextForShareTag(parsedTag);
const nextRefBase = {
contextId: context?.id || parsedTag.key,
key: parsedTag.key,
kind: (context?.kind || "bucket") as "user" | "bucket"
};
const existing = existingRefs.find(ref =>
ref.kind === nextRefBase.kind &&
(ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key)
);
return {
...nextRefBase,
policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(nextRefBase)
};
};
export const getTaskShareModeForTag = (task: Pick<Task, "shareRefs">, tag: string): "collaborative" | "handoff" | undefined => {
const parsed = parseTags([tag])[0];
if (!parsed || (parsed.kind !== "share" && parsed.kind !== "note")) return undefined;
const ref = task.shareRefs.find(existing =>
existing.key === parsed.key && (
(parsed.kind === "note" && existing.kind === "note") ||
(parsed.kind === "share" && existing.kind !== "note")
)
);
return ref ? getTaskSharePolicy(ref) : undefined;
};
export const deriveTaskRelationsFromTags = (
tags: string[],
existingShareRefs: TaskShareRef[] = [],
shareModeOverrides: Record<string, "collaborative" | "handoff"> = {}
) => {
const parsedTags = parseTags(tags);
const labelTags = parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display);
const shareRefs = parsedTags
.filter((tag): tag is Extract<typeof tag, { kind: "share" | "note" }> => tag.kind === "share" || tag.kind === "note")
.map(tag => buildTaskShareRef(tag, existingShareRefs, shareModeOverrides));
const noteRefs = parsedTags
.filter(tag => tag.kind === "note")
.map(tag => {
const note = store.notes.find(existing => !existing.deletedAt && existing.key === tag.key);
return {
noteId: note?.id || tag.key,
key: tag.key
} satisfies NoteRef;
});
return {
labelTags,
shareRefs: dedupeShareRefs(shareRefs),
noteRefs: dedupeNoteRefs(noteRefs)
};
};
export 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;
});
};
export const setContexts = (contexts: ShareContext[]) => {
setStore("contexts", reconcile(contexts));
setStore("buckets", reconcile(deriveBuckets(contexts)));
setStore("tasks", tasks => tasks.map(task => {
const favoriteTag = task.tags.find(tag => tag.endsWith("_favorite__"));
return {
...task,
tags: buildTaskTags(task.labelTags, task.shareRefs, task.noteRefs, favoriteTag)
};
}));
const labelDefinitions = store.tagDefinitions.filter(def => !def.name.startsWith("@"));
const mergedDefs = [...labelDefinitions, ...deriveSystemTagDefinitions(contexts)];
setStore("tagDefinitions", reconcile(mergedDefs));
};
export const mapContextToShareRule = (context: ShareContext): ShareRule => ({
id: context.id,
ownerId: pb.authStore.model?.id || "",
targetUserId: context.targetUserId || pb.authStore.model?.id || "",
type: "tag",
tagName: buildShareTag(context.displayName),
share_mode: context.policy === "handoff" ? "SEND_TASK" : "ADD_USER"
});
export const mapRecordToContext = (r: any): ShareContext => ({
id: r.id,
key: r.key || normalizeContextKey(r.displayName || r.name || ""),
displayName: r.displayName || r.name || r.key,
kind: (r.kind || "bucket") as "user" | "bucket",
policy: (r.policy || (r.kind === "user" ? "collaborative" : "handoff")) as "collaborative" | "handoff",
targetUserId: unwrapRelationId(r.targetUserId),
supervisorUserIds: unwrapRelationIds(r.supervisorUserIds),
color: r.color,
description: r.description,
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : null,
deletedBy: unwrapRelationId(r.deletedBy)
});
export const loadContextsIntoStore = async () => {
const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({
sort: "displayName,key",
requestKey: null
}).catch(() => []);
const nextContexts = contextRecords.map(mapRecordToContext);
setContexts(nextContexts);
setStore("shareRules", nextContexts.map(mapContextToShareRule));
return nextContexts;
};
export const getPersonalContext = (userId = pb.authStore.model?.id) =>
userId
? store.contexts.find(context =>
!context.deletedAt &&
context.kind === "user" &&
context.targetUserId === userId
) || 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, '\\"');
export 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";
};
export 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;
};
export 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");
export const taskHasPersonalContextAccess = (task: Pick<Task, "ownerId" | "shareRefs">, userId: string, allowLegacyFallback = true) =>
getTaskPersonalUserIds(task).includes(userId) ||
(allowLegacyFallback && taskHasLegacyPersonalAccess(task, userId));
export 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;
};
export const withMirroredOwnerId = (
currentOwnerId: string | null | undefined,
shareRefs: TaskShareRef[],
updates: Partial<Task>,
preferredOwnerId?: string | null
) => ({
...updates,
ownerId: resolveMirroredOwnerId(currentOwnerId, shareRefs, preferredOwnerId)
});
export const withActiveContextTags = (rawTags: string[]) => {
const nextTags = [...rawTags];
const ctx = currentTaskContext();
if (ctx === "mine") {
const personalContext = getPersonalContext();
if (personalContext) {
const personalTag = buildShareTag(personalContext.displayName);
if (!nextTags.some(tag => tag.toLowerCase() === personalTag.toLowerCase())) {
nextTags.push(personalTag);
}
}
} else if (typeof ctx === "object") {
if ("bucketId" in ctx && ctx.name) {
const bucketContext = store.contexts.find(context => context.id === ctx.bucketId);
const bucketTag = buildShareTag(bucketContext?.displayName || ctx.name);
if (!nextTags.some(tag => tag.toLowerCase() === bucketTag.toLowerCase())) {
nextTags.push(bucketTag);
}
} else if ("userId" in ctx && ctx.name) {
const userTag = buildShareTag(ctx.name);
if (!nextTags.some(tag => tag.toLowerCase() === userTag.toLowerCase())) {
nextTags.push(userTag);
}
}
}
if (store.isNotepadMode) {
const noteId = activeNoteId();
const note = noteId ? store.notes.find(existing => existing.id === noteId) : null;
if (note) {
const noteTag = buildNoteTag(note.key || note.title);
if (!nextTags.some(tag => tag.toLowerCase() === noteTag.toLowerCase())) {
nextTags.push(noteTag);
}
}
}
return [...new Set(nextTags)];
};
export const isCurrentUserContextTag = (tag: string) => {
const normalizedTag = tag.trim().toLowerCase();
if (!normalizedTag.startsWith("@")) return false;
const personalContext = getPersonalContext();
const currentUserDisplayName = (pb.authStore.model?.name || pb.authStore.model?.email || "").trim();
const hiddenTags = new Set<string>();
if (personalContext?.displayName) {
hiddenTags.add(buildShareTag(personalContext.displayName).toLowerCase());
}
if (currentUserDisplayName) {
hiddenTags.add(buildShareTag(currentUserDisplayName).toLowerCase());
}
return hiddenTags.has(normalizedTag);
};
export const isTaskLockedNoteTag = (task: Pick<Task, "tags" | "shareRefs" | "noteRefs">, tag: string) => {
const normalizedTag = tag.trim();
if (!normalizedTag.startsWith("#")) return false;
const noteKey = normalizeNoteKey(normalizedTag);
const matchingNoteRefs = task.noteRefs.filter(ref => ref.key === noteKey || ref.noteId === noteKey);
if (matchingNoteRefs.length === 0) return false;
const nonNoteContexts = task.shareRefs.filter(ref => ref.kind !== "note");
const uniqueNoteRefs = dedupeNoteRefs(task.noteRefs);
return uniqueNoteRefs.length === matchingNoteRefs.length && nonNoteContexts.length === 0;
};
export const getVisibleTaskTags = (task: Pick<Task, "tags" | "shareRefs" | "noteRefs">, context: TaskContext = currentTaskContext()) => {
const tags = task.tags || [];
return tags.filter(tag => {
if (tag.endsWith("_favorite__") || isCurrentUserContextTag(tag)) {
return false;
}
if (isTaskLockedNoteTag(task, tag)) {
return false;
}
if (typeof context === "object" && "bucketId" in context) {
const bucketContext = store.contexts.find(existing => existing.id === context.bucketId);
const bucketTagName = buildShareTag(bucketContext?.displayName || context.name).toLowerCase();
if (tag.toLowerCase() === bucketTagName) {
return false;
}
}
return true;
});
};
export const getSubscribedBucketContexts = () =>
store.subscribedBuckets
.map(id => store.contexts.find(context => context.id === id))
.filter((context): context is ShareContext => !!context && !context.deletedAt && context.kind === "bucket");
export const shouldTaskBeVisible = (task: Task, currentUserId: string): boolean => {
if (taskHasPersonalContextAccess(task, currentUserId)) return true;
if (store.supervisedContexts.some(context => taskHasPersonalContextAccess(task, context.ownerUserId))) return true;
const hasSubscribedBucket = task.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 = task.noteRefs.some(ref => {
const note = getNoteByRef(ref);
return !!note && (!note.isPrivate || note.user === currentUserId);
});
if (hasAccessibleNote) return true;
return task.shareRefs.some(ref => {
if (ref.kind !== "user" || getTaskSharePolicy(ref) !== "collaborative") return false;
const context = getContextByRef(ref);
return !!context && !context.deletedAt && context.targetUserId === currentUserId;
});
};
export const syncUserContexts = async () => {
const allUsers = await pb.collection("users").getFullList({
filter: "verified = true",
fields: "id,name,email",
requestKey: null
});
const existingContexts = await pb.collection(CONTEXTS_COLLECTION).getFullList({
requestKey: null
}).catch(() => []);
let createdAny = false;
for (const user of allUsers) {
const displayName = user.name || user.email;
const existingUserContext = existingContexts.find((context: any) =>
(context.kind || "bucket") === "user" &&
unwrapRelationId(context.targetUserId) === user.id
);
if (!displayName || existingUserContext) continue;
const created = await pb.collection(CONTEXTS_COLLECTION).create({
key: buildUserContextKey(user.id),
displayName,
kind: "user",
policy: "collaborative",
targetUserId: user.id
}, { requestKey: null }).catch(() => null);
if (created) {
createdAny = true;
}
}
if (createdAny || store.contexts.length === 0) {
await loadContextsIntoStore();
}
};
export const syncSupervisedContexts = async () => {
const currentUserId = pb.authStore.model?.id;
if (!currentUserId) {
setStore("supervisedContexts", []);
return;
}
try {
const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({
sort: "displayName,key",
requestKey: null
}).catch(() => []);
const contexts = contextRecords.length > 0 ? contextRecords.map(mapRecordToContext) : store.contexts;
if (contextRecords.length > 0) {
setContexts(contexts);
setStore("shareRules", contexts.map(mapContextToShareRule));
}
const nextSupervisedContexts = contexts
.filter(context =>
!context.deletedAt &&
context.kind === "user" &&
!!context.targetUserId &&
context.targetUserId !== currentUserId &&
(context.supervisorUserIds || []).includes(currentUserId)
)
.map(context => ({
ownerUserId: context.targetUserId as string,
ownerName: context.displayName,
contextId: context.id,
contextName: context.displayName
}))
.sort((a, b) => a.ownerName.localeCompare(b.ownerName));
setStore("supervisedContexts", nextSupervisedContexts);
} catch (err) {
console.error("Failed to load supervised contexts:", err);
}
};
export const toggleBucketSubscription = async (bucketId: string) => {
const currentSubscribed = store.subscribedBuckets;
const isSubscribed = currentSubscribed.includes(bucketId);
const newSubscribed = isSubscribed
? currentSubscribed.filter(id => id !== bucketId)
: [...currentSubscribed, bucketId];
setStore("subscribedBuckets", newSubscribed);
const userId = pb.authStore.model?.id;
if (userId) {
try {
const user = await pb.collection("users").getOne(userId, { requestKey: null });
const rawPrefs = user.Taskgrid_pref || {};
const scopedPrefs = readScopedPrefs(rawPrefs);
await pb.collection("users").update(userId, {
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
...scopedPrefs,
subscribedBuckets: newSubscribed
})
});
if (!isSubscribed) {
const bucket = store.buckets.find(b => b.id === bucketId);
if (bucket) {
const { getTaskListFields, mapRecordToTask } = await import("./tasks");
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `tags ~ "${buildShareTag(bucket.name)}"`,
sort: "-created",
fields: getTaskListFields()
});
const newTasks: Task[] = [];
records.forEach((r: any) => {
const exists = store.tasks.find(t => t.id === r.id);
if (!exists && !r.tags?.includes("__template__")) {
newTasks.push(mapRecordToTask(r));
}
});
if (newTasks.length > 0) {
setStore("tasks", t => [...newTasks, ...t]);
}
}
}
} catch (err) {
console.error("Failed to update subscriptions:", err);
setStore("subscribedBuckets", currentSubscribed);
}
}
};
export const createBucket = async (name: string, color: string, description?: string) => {
try {
const key = normalizeContextKey(name);
await pb.collection(CONTEXTS_COLLECTION).create({
key,
displayName: name,
kind: "bucket",
policy: "handoff",
color,
description
});
toast.success("Bucket created");
} catch (err) {
console.error("Failed to create bucket:", err);
toast.error("Failed to create bucket");
}
};
export const updateBucket = async (id: string, data: { name?: string, color?: string, description?: string, policy?: "collaborative" | "handoff" }) => {
try {
const payload: any = { ...data };
if (data.name) payload.key = normalizeContextKey(data.name);
if (data.name) payload.displayName = data.name;
await pb.collection(CONTEXTS_COLLECTION).update(id, payload);
toast.success("Bucket updated");
} catch (err) {
console.error("Failed to update bucket:", err);
toast.error("Failed to update bucket");
}
};
export const deleteBucket = async (id: string) => {
try {
await pb.collection(CONTEXTS_COLLECTION).update(id, {
deletedAt: new Date().toISOString(),
deletedBy: pb.authStore.model?.id || null
});
toast.success("Bucket moved to trash");
} catch (err) {
console.error("Failed to delete bucket:", err);
toast.error("Failed to delete bucket");
}
};
export const updateUserContextPolicy = async (contextId: string, policy: "collaborative" | "handoff") => {
const success = await setContextPolicy(contextId, policy);
if (success) {
toast.success("User context updated");
}
};
export const setContextPolicy = async (contextId: string, policy: "collaborative" | "handoff") => {
const previousContext = store.contexts.find(context => context.id === contextId);
const previousPolicy = previousContext?.policy;
if (previousContext && previousPolicy !== policy) {
setStore("contexts", context => context.id === contextId, "policy", policy);
setStore("shareRules", store.contexts.map(mapContextToShareRule));
}
try {
await pb.collection(CONTEXTS_COLLECTION).update(contextId, { policy }, { requestKey: null });
return true;
} catch (err) {
console.error("Failed to update user context:", err);
if (previousContext && previousPolicy) {
setStore("contexts", context => context.id === contextId, "policy", previousPolicy);
setStore("shareRules", store.contexts.map(mapContextToShareRule));
}
toast.error("Failed to update user context");
return false;
}
};
export const setNoteContextPolicy = async (noteId: string, policy: "collaborative" | "handoff") => {
if (!pb.authStore.isValid || !noteId) return false;
const note = store.notes.find(existing => existing.id === noteId);
if (!note) return false;
const baseTags = (note.tags || []).filter(tag => tag !== NOTE_HANDOFF_POLICY_TAG);
const nextTags = policy === "handoff" ? [...baseTags, NOTE_HANDOFF_POLICY_TAG] : baseTags;
const previousTags = note.tags || [];
setStore("notes", n => n.id === noteId, "tags", nextTags);
try {
await pb.collection(NOTES_COLLECTION).update(noteId, { tags: nextTags }, { requestKey: null });
return true;
} catch (err) {
console.error("Failed to update note context policy:", err);
setStore("notes", n => n.id === noteId, "tags", previousTags);
toast.error("Failed to update note sharing mode");
return false;
}
};
export const savePersonalContextSupervisors = async (supervisorUserIds: string[]) => {
const userId = pb.authStore.model?.id;
if (!userId) return;
const uniqueSupervisorIds = [...new Set(supervisorUserIds.filter(Boolean))];
setStore("personalContextSupervisorIds", uniqueSupervisorIds);
try {
await syncUserContexts();
const personalContext = getPersonalContext(userId);
if (!personalContext) {
throw new Error("Missing personal context record");
}
await pb.collection(CONTEXTS_COLLECTION).update(personalContext.id, {
supervisorUserIds: uniqueSupervisorIds
}, { requestKey: null });
const user = await pb.collection("users").getOne(userId, { requestKey: null });
const rawPrefs = user.Taskgrid_pref || {};
const scopedPrefs = readScopedPrefs(rawPrefs);
await pb.collection("users").update(userId, {
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
...scopedPrefs,
supervisorUserIds: uniqueSupervisorIds
})
}, { requestKey: null });
await syncSupervisedContexts();
toast.success("Personal supervision updated");
} catch (err) {
console.error("Failed to update personal supervision:", err);
toast.error("Failed to update personal supervision");
}
};
export const loadShareRules = async () => {
if (!pb.authStore.isValid) return;
setStore("shareRules", reconcile(store.contexts.map(mapContextToShareRule)));
};
export const addShareRule = async (type: "tag" | "all", targetUserId: string, tagName?: string, share_mode: "ADD_USER" | "SEND_TASK" = "SEND_TASK") => {
if (!pb.authStore.isValid) return;
if (type !== "tag" || !tagName?.startsWith("@")) {
toast.error("Only @tag contexts are supported now.");
return;
}
const existing = store.contexts.find(context => context.targetUserId === targetUserId && buildShareTag(context.displayName) === tagName);
if (existing) return;
try {
await pb.collection(CONTEXTS_COLLECTION).create({
key: normalizeContextKey(tagName),
displayName: tagName.replace(/^@/, ""),
kind: "user",
policy: share_mode === "SEND_TASK" ? "handoff" : "collaborative",
targetUserId
}, { requestKey: null });
toast.success("Shared context created");
} catch (err) {
console.error("Failed to create shared context:", err);
toast.error("Failed to create shared context.");
}
};
export const updateShareRule = async (ruleId: string, updates: Partial<ShareRule>) => {
if (!pb.authStore.isValid) return;
try {
const context = store.contexts.find(existing => existing.id === ruleId);
if (!context) return;
await pb.collection(CONTEXTS_COLLECTION).update(ruleId, {
policy: updates.share_mode === "SEND_TASK" ? "handoff" : "collaborative"
}, { requestKey: null });
toast.success("Context updated");
} catch (err) {
console.error("Failed to update share rule:", err);
toast.error("Failed to update share rule.");
}
};
export const removeShareRule = async (ruleId: string) => {
if (!pb.authStore.isValid) return;
try {
await pb.collection(CONTEXTS_COLLECTION).delete(ruleId);
toast.success("Context removed");
} catch (err) {
console.error("Failed to delete share rule:", err);
toast.error("Failed to delete share rule.");
}
};
export const syncSystemTagsAndRules = async () => {
if (!pb.authStore.isValid) return;
setStore("shareRules", store.contexts.map(mapContextToShareRule));
setStore("tagDefinitions", defs => {
const plainDefs = defs.filter(def => !def.name.startsWith("@"));
return [...plainDefs, ...deriveSystemTagDefinitions(store.contexts)];
});
};