865 lines
32 KiB
TypeScript
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)];
|
|
});
|
|
};
|