From f9ab95559c8dd806a0bf089c0207c41aa92deb94 Mon Sep 17 00:00:00 2001 From: Happyesss <22csaiml002@jssaten.ac.in> Date: Fri, 29 May 2026 12:38:46 +0530 Subject: [PATCH 1/2] feat: enhance GitHub integration with project permissions and update repository connection logic fix: improve project members filtering and prevent unauthorized access to repository actions refactor: streamline password history management in authentication flow fix: reset attachment preview in task modal on task change --- .../projects/[projectId]/github/client.tsx | 7 ++- .../projects/[projectId]/members/client.tsx | 32 +++++++---- src/features/audit-logs/utils.ts | 31 ++++------- src/features/auth/server/route.ts | 55 ++++++++++++++++++- .../components/enhanced-data-kanban.tsx | 33 ++++++++++- .../components/connect-repository.tsx | 5 ++ .../github-integration/server/route.ts | 41 +++++++++++--- .../tasks/components/task-preview-modal.tsx | 5 ++ 8 files changed, 164 insertions(+), 45 deletions(-) diff --git a/src/app/(dashboard)/workspaces/[workspaceId]/projects/[projectId]/github/client.tsx b/src/app/(dashboard)/workspaces/[workspaceId]/projects/[projectId]/github/client.tsx index a155647e..0bf6da34 100644 --- a/src/app/(dashboard)/workspaces/[workspaceId]/projects/[projectId]/github/client.tsx +++ b/src/app/(dashboard)/workspaces/[workspaceId]/projects/[projectId]/github/client.tsx @@ -33,11 +33,14 @@ import { COMMIT_CACHE_CHANNEL, } from "@/features/github-integration/lib/commit-cache"; import { useWorkspaceId } from "@/features/workspaces/hooks/use-workspace-id"; +import { useProjectPermissions } from "@/hooks/use-project-permissions"; export const GitHubIntegrationClient = () => { const projectId = useProjectId(); const workspaceId = useWorkspaceId(); const { data: repository, isLoading } = useGetRepository(projectId); + const { isProjectAdmin } = useProjectPermissions({ projectId, workspaceId }); + const canManageGithub = isProjectAdmin; const [commitsCount, setCommitsCount] = useState(0); const documentationPath = workspaceId ? `/workspaces/${workspaceId}/projects/${projectId}/github/documentation` @@ -188,7 +191,7 @@ export const GitHubIntegrationClient = () => { - + {/* Connection node indicator */} @@ -438,7 +441,7 @@ export const GitHubIntegrationClient = () => {
- +
diff --git a/src/app/(dashboard)/workspaces/[workspaceId]/projects/[projectId]/members/client.tsx b/src/app/(dashboard)/workspaces/[workspaceId]/projects/[projectId]/members/client.tsx index 5d607df7..abe5f14a 100644 --- a/src/app/(dashboard)/workspaces/[workspaceId]/projects/[projectId]/members/client.tsx +++ b/src/app/(dashboard)/workspaces/[workspaceId]/projects/[projectId]/members/client.tsx @@ -157,18 +157,26 @@ export const ProjectMembersClient = () => { }); }; - const memberOptions = workspaceMembers.map((member) => ({ - label: member.name || "Unknown", - value: member.userId, - icon: () => ( - - - - {member.name?.charAt(0).toUpperCase()} - - - ), - })); + // IDs of members already in this project + const existingProjectMemberUserIds = new Set(projectMembers.map((m) => m.userId)); + + const memberOptions = workspaceMembers + // Exclude members already in the project + .filter((member) => !existingProjectMemberUserIds.has(member.userId)) + // Exclude workspace OWNER (they have implicit access to all projects) + .filter((member) => member.role !== "OWNER") + .map((member) => ({ + label: member.name || "Unknown", + value: member.userId, + icon: () => ( + + + + {member.name?.charAt(0).toUpperCase()} + + + ), + })); if (isLoading) { return ( diff --git a/src/features/audit-logs/utils.ts b/src/features/audit-logs/utils.ts index eb308d12..90db0ea3 100644 --- a/src/features/audit-logs/utils.ts +++ b/src/features/audit-logs/utils.ts @@ -164,14 +164,10 @@ export async function getActivityLogs({ // Now create activity logs with resolved user info for (const { doc, activityType, activityAction } of tempActivities) { - let userId = getUserIdFromDocument(doc, activityType, activityAction); + const userId = getUserIdFromDocument(doc, activityType, activityAction); - // Don't use currentUserId as fallback - we want to show "Unknown User" - // when we genuinely don't know who performed the action - // Only fallback to workspace members for created items where we have no user field - if (!userId && activityAction === "created" && membersResult.documents.length > 0) { - userId = membersResult.documents[0].userId as string; - } + // No fallback to workspace members — that would attribute actions to the wrong person. + // If we can't determine the user, show "Unknown User". const userInfo = userId ? userMap.get(userId) : null; @@ -250,23 +246,17 @@ export async function getActivityLogs({ function getUserIdFromDocument( doc: Record, activityType: ActivityType, - action?: string + _action?: string ): string | undefined { - // First, check if document has lastModifiedBy field (for updates) - if (action === "updated" && doc.lastModifiedBy) { + // Check lastModifiedBy first — it's set on both task creates AND updates + if (doc.lastModifiedBy) { return doc.lastModifiedBy as string; } switch (activityType) { case ActivityType.TASK: - // For task creates, use assigneeId or the first assigneeIds - // For updates, we now check lastModifiedBy first (above) - if (action === "created") { - const assigneeId = doc.assigneeId as string | undefined; - const assigneeIds = doc.assigneeIds as string[] | undefined; - return assigneeId || (assigneeIds?.[0]); - } - // For updates without lastModifiedBy, return undefined + // lastModifiedBy already handled above for both creates and updates. + // Return undefined — we don't want to fall back to the assignee as the "author". return undefined; case ActivityType.TIME_LOG: @@ -298,10 +288,11 @@ function getUserIdFromDocument( case ActivityType.PROJECT: case ActivityType.SPRINT: + // Both have a createdBy field stored at creation time + return (doc.createdBy as string | undefined); + case ActivityType.CUSTOM_COLUMN: case ActivityType.NOTIFICATION: - // These don't have direct user fields - // We'll need to infer from workspace membership return undefined; default: diff --git a/src/features/auth/server/route.ts b/src/features/auth/server/route.ts index ef9977c1..f1611c49 100644 --- a/src/features/auth/server/route.ts +++ b/src/features/auth/server/route.ts @@ -2,6 +2,33 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { deleteCookie, setCookie } from "hono/cookie"; import { z } from "zod"; +import { scrypt, randomBytes, timingSafeEqual } from "crypto"; +import { promisify } from "util"; + +const scryptAsync = promisify(scrypt); +const PASSWORD_HISTORY_LIMIT = 10; + +async function hashPasswordForHistory(password: string): Promise { + const salt = randomBytes(16); + const hash = (await scryptAsync(password, salt, 64)) as Buffer; + return `${salt.toString("hex")}:${hash.toString("hex")}`; +} + +async function isPasswordInHistory(password: string, history: string[]): Promise { + for (const storedHash of history) { + try { + const [saltHex, hashHex] = storedHash.split(":"); + if (!saltHex || !hashHex) continue; + const salt = Buffer.from(saltHex, "hex"); + const storedHashBuffer = Buffer.from(hashHex, "hex"); + const computedHash = (await scryptAsync(password, salt, 64)) as Buffer; + if (timingSafeEqual(storedHashBuffer, computedHash)) return true; + } catch { + continue; + } + } + return false; +} import { loginSchema, @@ -669,11 +696,37 @@ const app = new Hono() async (c) => { try { const account = c.get("account"); + const user = c.get("user"); const { currentPassword, newPassword } = c.req.valid("json"); - // Update password using Appwrite's updatePassword method + // Check new password against previously used password history + const passwordHistory: string[] = Array.isArray(user.prefs?.passwordHistory) + ? (user.prefs.passwordHistory as string[]) + : []; + + if (await isPasswordInHistory(newPassword, passwordHistory)) { + return c.json({ + error: "You have used this password before. Please choose a password you haven't used previously.", + }, 400); + } + + // Update password — Appwrite verifies currentPassword internally await account.updatePassword(newPassword, currentPassword); + // Hash the retired password and prepend to history (keep last N) + const retiredHash = await hashPasswordForHistory(currentPassword); + const updatedHistory = [retiredHash, ...passwordHistory].slice(0, PASSWORD_HISTORY_LIMIT); + + const currentPrefs: Record = {}; + if (user.prefs && typeof user.prefs === "object" && !Array.isArray(user.prefs)) { + Object.entries(user.prefs).forEach(([k, v]) => { + if (v !== undefined && v !== null && typeof v !== "function") { + currentPrefs[k] = v; + } + }); + } + await account.updatePrefs({ ...currentPrefs, passwordHistory: updatedHistory }); + return c.json({ success: true, message: "Password updated successfully" }); } catch (error: unknown) { diff --git a/src/features/custom-columns/components/enhanced-data-kanban.tsx b/src/features/custom-columns/components/enhanced-data-kanban.tsx index da225447..08b7aa7b 100644 --- a/src/features/custom-columns/components/enhanced-data-kanban.tsx +++ b/src/features/custom-columns/components/enhanced-data-kanban.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useEffect, useState, useMemo } from "react"; +import React, { useCallback, useEffect, useState, useMemo, useRef } from "react"; import { DragDropContext, Droppable, @@ -169,6 +169,9 @@ export const EnhancedDataKanban = ({ const [tasks, setTasks] = useState({}); const [orderedColumns, setOrderedColumns] = useState([]); + // Track current orderedColumns in a ref so the sync effect never goes stale + const orderedColumnsRef = useRef(orderedColumns); + orderedColumnsRef.current = orderedColumns; const [selectedTasks, setSelectedTasks] = useState>(new Set()); const [selectionMode, setSelectionMode] = useState(false); @@ -215,9 +218,33 @@ export const EnhancedDataKanban = ({ }); }; - // Update ordered columns when allColumns changes + // Sync orderedColumns from allColumns ONLY when the set of column IDs changes + // (initial load, or columns added/removed). Ignores position-only changes so + // that a manual drag-reorder is never snapped back by a subsequent re-render. useEffect(() => { - setOrderedColumns(allColumns); + const current = orderedColumnsRef.current; + const currentIds = new Set(current.map((c) => c.id)); + const newIds = new Set(allColumns.map((c) => c.id)); + + const hasAdded = allColumns.some((c) => !currentIds.has(c.id)); + const hasRemoved = current.some((c) => !newIds.has(c.id)); + + // Initial load — set directly + if (current.length === 0) { + setOrderedColumns(allColumns); + return; + } + + // No structural change — preserve the user's manual order + if (!hasAdded && !hasRemoved) return; + + // Columns were added or removed — merge while preserving current order + const allColumnsMap = new Map(allColumns.map((c) => [c.id, c])); + const merged = current + .filter((c) => newIds.has(c.id)) + .map((c) => allColumnsMap.get(c.id)!) + .concat(allColumns.filter((c) => !currentIds.has(c.id))); + setOrderedColumns(merged); }, [allColumns]); // Update tasks when data changes or columns change diff --git a/src/features/github-integration/components/connect-repository.tsx b/src/features/github-integration/components/connect-repository.tsx index 7d085349..4955739e 100644 --- a/src/features/github-integration/components/connect-repository.tsx +++ b/src/features/github-integration/components/connect-repository.tsx @@ -50,11 +50,13 @@ type FormValues = z.infer; interface ConnectRepositoryProps { projectId: string; isUpdate?: boolean; + canManage?: boolean; } export const ConnectRepository = ({ projectId, isUpdate = false, + canManage = false, }: ConnectRepositoryProps) => { const [open, setOpen] = useState(false); const [isCheckingRepo, setIsCheckingRepo] = useState(false); @@ -174,6 +176,7 @@ export const ConnectRepository = ({ }; if (isUpdate && repository) { + if (!canManage) return null; return ( <> @@ -330,6 +333,8 @@ export const ConnectRepository = ({ ); } + if (!canManage) return null; + return ( diff --git a/src/features/github-integration/server/route.ts b/src/features/github-integration/server/route.ts index 51f65586..8b7a92de 100644 --- a/src/features/github-integration/server/route.ts +++ b/src/features/github-integration/server/route.ts @@ -49,6 +49,28 @@ const app = new Hono() return c.json({ error: "Unauthorized" }, 401); } + // Check if repo is already linked (determines connect vs. update) + const existing = await databases.listDocuments( + DATABASE_ID, + GITHUB_REPOS_ID, + [Query.equal("projectId", projectId)] + ); + + // RBAC: Only project admins/owners can create new repository connections. + // All project members can update/refetch an existing connection. + if (existing.total === 0) { + const { resolveUserProjectAccess } = await import( + "@/lib/permissions/resolveUserProjectAccess" + ); + const access = await resolveUserProjectAccess(databases, user.$id, projectId); + if (!access.isAdmin) { + return c.json( + { error: "Only project admins and owners can connect repositories" }, + 403 + ); + } + } + // Parse GitHub URL const { owner, repo } = githubAPI.parseGitHubUrl(githubUrl); @@ -67,13 +89,6 @@ const app = new Hono() ); } - // Check if repo is already linked - const existing = await databases.listDocuments( - DATABASE_ID, - GITHUB_REPOS_ID, - [Query.equal("projectId", projectId)] - ); - let repository: GitHubRepository; if (existing.total > 0) { @@ -259,6 +274,18 @@ const app = new Hono() return c.json({ error: "Unauthorized" }, 401); } + // RBAC: Only project admins/owners can disconnect repositories + const { resolveUserProjectAccess } = await import( + "@/lib/permissions/resolveUserProjectAccess" + ); + const access = await resolveUserProjectAccess(databases, user.$id, repository.projectId); + if (!access.isAdmin) { + return c.json( + { error: "Only project admins and owners can disconnect repositories" }, + 403 + ); + } + // Delete repository connection await databases.deleteDocument( DATABASE_ID, diff --git a/src/features/tasks/components/task-preview-modal.tsx b/src/features/tasks/components/task-preview-modal.tsx index 2f605af8..750583be 100644 --- a/src/features/tasks/components/task-preview-modal.tsx +++ b/src/features/tasks/components/task-preview-modal.tsx @@ -760,6 +760,11 @@ export const TaskPreviewModalWrapper = () => { const closeAttachmentPreview = () => setPreviewAttachment(null); + // Reset attachment preview when the task changes + useEffect(() => { + setPreviewAttachment(null); + }, [taskId]); + // Prevent body scroll when modal is open useEffect(() => { if (isOpen) { From 1bd7c11998cbba438d056930866b63ba93a4a953 Mon Sep 17 00:00:00 2001 From: Happyesss <22csaiml002@jssaten.ac.in> Date: Sat, 30 May 2026 22:42:20 +0530 Subject: [PATCH 2/2] feat: implement kanban auto-scroll functionality for drag-and-drop interactions --- .../components/enhanced-data-kanban.tsx | 15 ++- .../sprints/components/my-work-view.tsx | 12 ++- src/features/tasks/components/data-kanban.tsx | 12 ++- src/hooks/use-kanban-auto-scroll.ts | 93 +++++++++++++++++++ src/hooks/use-realtime-notifications.ts | 6 -- src/lib/notifications.ts | 2 +- src/lib/notifications/dispatcher.ts | 2 +- 7 files changed, 128 insertions(+), 14 deletions(-) create mode 100644 src/hooks/use-kanban-auto-scroll.ts diff --git a/src/features/custom-columns/components/enhanced-data-kanban.tsx b/src/features/custom-columns/components/enhanced-data-kanban.tsx index 08b7aa7b..5114de6a 100644 --- a/src/features/custom-columns/components/enhanced-data-kanban.tsx +++ b/src/features/custom-columns/components/enhanced-data-kanban.tsx @@ -48,6 +48,7 @@ import { useValidateTransition, TransitionValidationResult } from "@/features/wo import { useGetCustomColumns } from "../api/use-get-custom-columns"; import { useDefaultColumns } from "../hooks/use-default-columns"; import { CustomColumnHeader } from "./custom-column-header"; +import { useKanbanAutoScroll } from "@/hooks/use-kanban-auto-scroll"; import { CustomColumn } from "../types"; import { useUpdateColumnOrder } from "@/features/default-column-settings/api/use-update-column-order"; @@ -117,6 +118,7 @@ export const EnhancedDataKanban = ({ useCreateTaskModal(); + const { scrollRef, handleDragStart, handleDragEnd } = useKanbanAutoScroll(); const { getEnabledColumns } = useDefaultColumns(workspaceId, projectId); const { mutate: updateColumnOrder } = useUpdateColumnOrder(); @@ -628,12 +630,21 @@ export const EnhancedDataKanban = ({ - + { + handleDragEnd(); + onDragEnd(result); + }} + onDragStart={handleDragStart} + > {(provided) => (
{ + scrollRef.current = el; + provided.innerRef(el); + }} className="flex overflow-x-scroll gap-4 pb-4 kanban-scrollbar" > {orderedColumns.map((column, index) => { diff --git a/src/features/sprints/components/my-work-view.tsx b/src/features/sprints/components/my-work-view.tsx index 9c528eac..bd8298d1 100644 --- a/src/features/sprints/components/my-work-view.tsx +++ b/src/features/sprints/components/my-work-view.tsx @@ -26,6 +26,7 @@ import { import { useWorkspaceId } from "@/features/workspaces/hooks/use-workspace-id"; import { useCurrentMember } from "@/features/members/hooks/use-current-member"; import { useGetProjects } from "@/features/projects/api/use-get-projects"; +import { useKanbanAutoScroll } from "@/hooks/use-kanban-auto-scroll"; import { Input } from "@/components/ui/input"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -246,6 +247,7 @@ export const MyWorkView = () => { }, [workItems]); // Handle drag and drop + const { scrollRef, handleDragStart, handleDragEnd } = useKanbanAutoScroll(); const onDragEnd = useCallback( (result: DropResult) => { if (!result.destination) return; @@ -495,8 +497,14 @@ export const MyWorkView = () => {
) : view === "board" ? ( /* Board View - Kanban Style with drag and drop */ - -
+ { + handleDragEnd(); + onDragEnd(result); + }} + onDragStart={handleDragStart} + > +
{visibleColumns.map((column) => { const items = itemsByStatus[column.id] || []; return ( diff --git a/src/features/tasks/components/data-kanban.tsx b/src/features/tasks/components/data-kanban.tsx index 4ef11254..af5692da 100644 --- a/src/features/tasks/components/data-kanban.tsx +++ b/src/features/tasks/components/data-kanban.tsx @@ -20,6 +20,7 @@ import { useCreateTaskModal } from "../hooks/use-create-task-modal"; import { useGetProject } from "@/features/projects/api/use-get-project"; import { useValidateTransition, TransitionValidationResult } from "@/features/workflows/api/use-validate-transition"; import { useGetWorkflowStatuses } from "@/features/workflows/api/use-get-workflow-statuses"; +import { useKanbanAutoScroll } from "@/hooks/use-kanban-auto-scroll"; const boards: TaskStatus[] = [ TaskStatus.TODO, @@ -92,6 +93,7 @@ export const DataKanban = ({ const { mutate: bulkUpdateTasks } = useBulkUpdateTasks(); const { open: openCreateTask } = useCreateTaskModal(); const { mutateAsync: validateTransition } = useValidateTransition(); + const { scrollRef, handleDragStart, handleDragEnd } = useKanbanAutoScroll(); // Check if TODO column should be visible (only when tasks are TODO or unassigned) const shouldShowTodoColumn = useMemo(() => { @@ -456,8 +458,14 @@ export const DataKanban = ({
- -
+ { + handleDragEnd(); + onDragEnd(result); + }} + onDragStart={handleDragStart} + > +
{visibleBoards.map((board) => { const selectedInColumn = tasks[board].filter(task => selectedTasks.has(task.$id) diff --git a/src/hooks/use-kanban-auto-scroll.ts b/src/hooks/use-kanban-auto-scroll.ts new file mode 100644 index 00000000..6bb0f45e --- /dev/null +++ b/src/hooks/use-kanban-auto-scroll.ts @@ -0,0 +1,93 @@ +"use client"; + +/** + * useKanbanAutoScroll + * + * Provides edge-based horizontal auto-scroll for a Kanban board container + * while the user is dragging a card. + * + * Usage: + * 1. Attach `scrollRef` to the horizontally scrollable container element. + * 2. Pass `onDragStart` / `onDragEnd` to . + * + * The hook listens to global `pointermove` events so it works even when the + * pointer has left the container boundary (which is exactly what happens when + * you drag towards the edge of the visible area). + */ + +import { useRef, useEffect, useCallback } from "react"; + +/** Distance from the container edge that triggers scrolling (px). */ +const SCROLL_ZONE = 120; +/** Maximum scroll speed (px per animation frame). */ +const MAX_SPEED = 16; + +export function useKanbanAutoScroll() { + const scrollRef = useRef(null); + const isDraggingRef = useRef(false); + const pointerXRef = useRef(0); + const rafRef = useRef(null); + + // ── animation loop ──────────────────────────────────────────────────────── + const tick = useCallback(() => { + const el = scrollRef.current; + if (!el || !isDraggingRef.current) return; + + const rect = el.getBoundingClientRect(); + const px = pointerXRef.current; + + const distLeft = px - rect.left; + const distRight = rect.right - px; + + if (distLeft < SCROLL_ZONE && distLeft > 0) { + // scroll left — faster the closer you are + const speed = MAX_SPEED * (1 - distLeft / SCROLL_ZONE); + el.scrollLeft -= speed; + } else if (distRight < SCROLL_ZONE && distRight > 0) { + // scroll right + const speed = MAX_SPEED * (1 - distRight / SCROLL_ZONE); + el.scrollLeft += speed; + } + + rafRef.current = requestAnimationFrame(tick); + }, []); + + // ── pointer tracking ─────────────────────────────────────────────────────── + useEffect(() => { + const handlePointerMove = (e: PointerEvent | MouseEvent) => { + pointerXRef.current = e.clientX; + }; + + // Use `pointermove` (covers touch + mouse). Fall back to `mousemove`. + window.addEventListener("pointermove", handlePointerMove, { passive: true }); + window.addEventListener("mousemove", handlePointerMove, { passive: true }); + + return () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("mousemove", handlePointerMove); + }; + }, []); + + // ── drag lifecycle callbacks ─────────────────────────────────────────────── + const handleDragStart = useCallback(() => { + isDraggingRef.current = true; + rafRef.current = requestAnimationFrame(tick); + }, [tick]); + + const handleDragEnd = useCallback(() => { + isDraggingRef.current = false; + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }, []); + + return { + /** Attach to the horizontally scrollable container element. */ + scrollRef, + /** Pass as `onDragStart` to . */ + handleDragStart, + /** Compose with the existing `onDragEnd` handler in . */ + handleDragEnd, + }; +} diff --git a/src/hooks/use-realtime-notifications.ts b/src/hooks/use-realtime-notifications.ts index 08889b6c..b2f5fd28 100644 --- a/src/hooks/use-realtime-notifications.ts +++ b/src/hooks/use-realtime-notifications.ts @@ -150,12 +150,6 @@ export function useRealtimeNotifications({ return; } - // TEMPORARY FIX: Suppress real-time disconnected errors in dev mode - if (process.env.NODE_ENV !== 'production') { - setIsConnected(false); - return; - } - const client = getAppwriteClient(); const databaseId = process.env.NEXT_PUBLIC_APPWRITE_DATABASE_ID!; const notificationsId = process.env.NEXT_PUBLIC_APPWRITE_NOTIFICATIONS_ID!; diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 9e61885d..c6249bd9 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -266,7 +266,7 @@ export async function createNotification({ workspaceId, triggeredBy, metadata: JSON.stringify(metadata), - read: false, + isRead: false, }, [ `read("user:${userId}")`, diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index bb4f4292..5584cdde 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -311,7 +311,7 @@ class NotificationDispatcher { workspaceId: event.workspaceId, triggeredBy: event.triggeredBy, metadata: JSON.stringify(payload.metadata || {}), - read: false, + isRead: false, }, [ `read("user:${userId}")`,