Files
tgex-frontend/components/providers/workspace-provider.tsx
2026-02-10 17:15:38 +03:00

575 lines
19 KiB
TypeScript

"use client";
// ============================================================================
// Workspace Provider
// ============================================================================
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
useMemo,
} from "react";
import { useParams, useRouter, usePathname } from "next/navigation";
import { workspacesApi, projectsApi, membersApi, authApi } from "@/lib/api";
import type {
Workspace,
Project,
Permission,
PermissionKey,
} from "@/lib/types/api";
import { STORAGE_KEYS } from "@/lib/utils/constants";
import { isDemoWorkspace, demoApi, demoWorkspace, demoProjects, DEMO_WORKSPACE_ID } from "@/lib/demo";
import { useAuth } from "@/components/auth/auth-provider";
// ============================================================================
// Types
// ============================================================================
interface WorkspaceContextType {
// Workspaces
workspaces: Workspace[];
currentWorkspace: Workspace | null;
setCurrentWorkspace: (workspace: Workspace) => void;
// Projects (Target Channels)
projects: Project[];
selectedProjects: Project[];
currentProject: Project | null; // First selected or null (for backward compatibility)
setSelectedProjects: (projects: Project[]) => void;
toggleProject: (project: Project) => void;
selectAllProjects: () => void;
clearProjectSelection: () => void;
// Helper for API filtering
// Returns project_id only if single project selected, undefined otherwise
getProjectFilterId: () => string | undefined;
// Returns true if all projects are selected
allProjectsSelected: boolean;
// Demo mode
isDemoMode: boolean;
// Permissions
permissions: Permission[];
hasPermission: (key: PermissionKey, projectId?: string, creativeId?: string, placementId?: string) => boolean;
isAdmin: boolean;
// Check if user is admin in a specific workspace
isWorkspaceAdmin: (workspaceId: string) => Promise<boolean>;
// Analytics permission helpers (memoized values, not functions)
hasAnyAnalyticsPermission: boolean;
canViewSubscriptions: boolean;
canViewAnalytics: boolean;
// Loading states
isLoading: boolean;
isLoadingProjects: boolean;
error: string | null;
// Actions
refreshWorkspaces: () => Promise<Workspace[]>;
refreshProjects: () => Promise<void>;
createWorkspace: (name: string, avatarFile?: File | null) => Promise<Workspace>;
}
const WorkspaceContext = createContext<WorkspaceContextType | undefined>(
undefined
);
// ============================================================================
// Hook
// ============================================================================
export const useWorkspace = () => {
const context = useContext(WorkspaceContext);
if (!context) {
throw new Error("useWorkspace must be used within WorkspaceProvider");
}
return context;
};
// ============================================================================
// Provider
// ============================================================================
interface WorkspaceProviderProps {
children: React.ReactNode;
}
export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
children,
}) => {
const params = useParams();
const router = useRouter();
const pathname = usePathname();
const { user } = useAuth();
// State
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [currentWorkspace, setCurrentWorkspaceState] = useState<Workspace | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProjects, setSelectedProjectsState] = useState<Project[]>([]);
const [permissions, setPermissions] = useState<Permission[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [error, setError] = useState<string | null>(null);
// Get workspaceId from URL params
const workspaceIdFromUrl = params?.workspaceId as string | undefined;
// Check if demo mode
const isDemoMode = isDemoWorkspace(workspaceIdFromUrl);
// Current project (first selected for backward compatibility)
const currentProject = selectedProjects.length > 0 ? selectedProjects[0] : null;
// Check if all projects are selected
const allProjectsSelected =
projects.length > 0 && selectedProjects.length === projects.length;
// Get project_id for API filtering
// Returns undefined if multiple projects selected (to get workspace-wide data)
const getProjectFilterId = useCallback(() => {
if (selectedProjects.length === 1) {
return selectedProjects[0].id;
}
// Multiple projects selected - return undefined to get all data
return undefined;
}, [selectedProjects]);
// ============================================================================
// Permission Helpers
// ============================================================================
const isAdmin = permissions.some((p) => p.key === "admin_full");
const hasPermission = useCallback(
(key: PermissionKey, projectId?: string, creativeId?: string, placementId?: string): boolean => {
// Admin has all permissions
if (isAdmin) return true;
// Find permission by key
const permission = permissions.find((p) => p.key === key);
if (!permission) return false;
// If no scopes defined, permission applies globally
if (!permission.scopes || permission.scopes.length === 0) return true;
// If projectId provided, check if it's in scopes
if (projectId) {
const hasProjectScope = permission.scopes.some(
(s) => s.type === "project" && s.id === projectId
);
if (hasProjectScope) return true;
}
// If creativeId provided, check if it's in scopes
if (creativeId) {
const hasCreativeScope = permission.scopes.some(
(s) => s.type === "creative" && s.id === creativeId
);
if (hasCreativeScope) return true;
}
// If placementId provided, check if it's in scopes
if (placementId) {
const hasPlacementScope = permission.scopes.some(
(s) => s.type === "placement" && s.id === placementId
);
if (hasPlacementScope) return true;
}
// Permission exists but is scoped and no matching scope found
return false;
},
[permissions, isAdmin]
);
// ============================================================================
// Analytics Permission Helpers
// ============================================================================
// Check if user has any analytics permission
const hasAnyAnalyticsPermission = useMemo((): boolean => {
if (isAdmin) return true;
return (
hasPermission("analytics_read") ||
hasPermission("analytics_without_clicks") ||
hasPermission("analytics_own_creatives")
);
}, [hasPermission, isAdmin]);
// Check if user can view subscriptions (false if only analytics_without_clicks)
const canViewSubscriptions = useMemo((): boolean => {
if (isAdmin) return true;
return hasPermission("analytics_read");
}, [hasPermission, isAdmin]);
// Check if user can view analytics (any analytics permission)
const canViewAnalytics = useMemo((): boolean => {
if (isAdmin) return true;
return hasAnyAnalyticsPermission;
}, [hasAnyAnalyticsPermission, isAdmin]);
// ============================================================================
// Load Workspaces
// ============================================================================
const loadWorkspaces = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
// Demo mode - use mock data, but also load real workspaces if user is logged in
if (isDemoMode) {
const allWorkspaces: Workspace[] = [demoWorkspace];
// If user is logged in, also load their real workspaces
if (user) {
try {
const response = await workspacesApi.list({ size: 100 });
allWorkspaces.push(...response.items);
} catch (err) {
// If loading real workspaces fails, just use demo workspace
console.warn("Failed to load real workspaces in demo mode:", err);
}
}
setWorkspaces(allWorkspaces);
setCurrentWorkspaceState(demoWorkspace);
setIsLoading(false);
return allWorkspaces;
}
const response = await workspacesApi.list({ size: 100 });
setWorkspaces(response.items);
// If we have workspaceId in URL, select that workspace
if (workspaceIdFromUrl && response.items.length > 0) {
const workspace = response.items.find((w) => w.id === workspaceIdFromUrl);
if (workspace) {
setCurrentWorkspaceState(workspace);
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, workspace.id);
} else {
// Workspace from URL not found, redirect to first available
const firstWorkspace = response.items[0];
setCurrentWorkspaceState(firstWorkspace);
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, firstWorkspace.id);
// Redirect to correct workspace
const newPath = pathname.replace(
`/dashboard/${workspaceIdFromUrl}`,
`/dashboard/${firstWorkspace.id}`
);
router.replace(newPath);
}
} else if (response.items.length > 0) {
// Try to restore from localStorage or use first
const savedId = localStorage.getItem(STORAGE_KEYS.SELECTED_WORKSPACE);
const saved = savedId
? response.items.find((w) => w.id === savedId)
: null;
const workspace = saved || response.items[0];
setCurrentWorkspaceState(workspace);
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, workspace.id);
}
return response.items;
} catch (err: any) {
console.error("Failed to load workspaces:", err);
setError(err?.error?.message || "Ошибка загрузки воркспейсов");
return [];
} finally {
setIsLoading(false);
}
}, [workspaceIdFromUrl, pathname, router, isDemoMode, user]);
// ============================================================================
// Load Projects & Permissions
// ============================================================================
const loadProjectsAndPermissions = useCallback(async (workspaceId: string) => {
try {
setIsLoadingProjects(true);
// Demo mode - use mock data
if (isDemoMode) {
const activeProjects = demoProjects.filter((p) => p.status === "active");
setProjects(activeProjects);
setPermissions([{ key: "admin_full" }]);
setSelectedProjectsState(activeProjects.length > 0 ? [activeProjects[0]] : []);
setIsLoadingProjects(false);
return;
}
// Load projects
const projectsResponse = await projectsApi.list(workspaceId, { size: 100 });
const activeProjects = projectsResponse.items.filter(
(p) => p.status === "active"
);
setProjects(activeProjects);
// Load current user's permissions
const me = await authApi.me();
const membersResponse = await membersApi.list(workspaceId, { size: 100 });
// Find member by user.id (not by member record id)
const currentMember = membersResponse.items.find((m) => m.user.id === me.id);
if (currentMember) {
// If member found but no permissions, they're likely the creator - grant admin
if (currentMember.permissions.length === 0) {
setPermissions([{ key: "admin_full" }]);
} else {
setPermissions(currentMember.permissions);
}
} else {
// User not in members list - might be creator, check if only workspace
// Grant admin access by default for workspace creators
if (membersResponse.items.length === 0) {
setPermissions([{ key: "admin_full" }]);
} else {
setPermissions([]);
}
}
// Restore selected projects from localStorage
const savedProjectIds = localStorage.getItem(
`${STORAGE_KEYS.SELECTED_PROJECT}_${workspaceId}`
);
if (savedProjectIds && activeProjects.length > 0) {
try {
const ids = JSON.parse(savedProjectIds) as string[];
const savedProjects = activeProjects.filter((p) => ids.includes(p.id));
if (savedProjects.length > 0) {
setSelectedProjectsState(savedProjects);
} else if (activeProjects.length > 0) {
setSelectedProjectsState([activeProjects[0]]);
}
} catch {
// Old format (single id), migrate to array
const savedProject = activeProjects.find((p) => p.id === savedProjectIds);
if (savedProject) {
setSelectedProjectsState([savedProject]);
} else if (activeProjects.length > 0) {
setSelectedProjectsState([activeProjects[0]]);
}
}
} else if (activeProjects.length > 0) {
setSelectedProjectsState([activeProjects[0]]);
} else {
setSelectedProjectsState([]);
}
} catch (err: any) {
console.error("Failed to load projects/permissions:", err);
} finally {
setIsLoadingProjects(false);
}
}, [isDemoMode]);
// ============================================================================
// Set Current Workspace
// ============================================================================
const setCurrentWorkspace = useCallback(
(workspace: Workspace) => {
setCurrentWorkspaceState(workspace);
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, workspace.id);
// Update URL if we're in dashboard
if (pathname.startsWith("/dashboard/")) {
const currentWorkspaceId = workspaceIdFromUrl;
if (currentWorkspaceId && currentWorkspaceId !== workspace.id) {
const newPath = pathname.replace(
`/dashboard/${currentWorkspaceId}`,
`/dashboard/${workspace.id}`
);
router.push(newPath);
}
} else if (pathname === "/dashboard" || pathname === "/") {
router.push(`/dashboard/${workspace.id}`);
}
// Load projects for new workspace
loadProjectsAndPermissions(workspace.id);
},
[pathname, router, workspaceIdFromUrl, loadProjectsAndPermissions]
);
// ============================================================================
// Project Selection Methods
// ============================================================================
const saveSelectedProjects = useCallback(
(selected: Project[]) => {
if (currentWorkspace) {
localStorage.setItem(
`${STORAGE_KEYS.SELECTED_PROJECT}_${currentWorkspace.id}`,
JSON.stringify(selected.map((p) => p.id))
);
}
},
[currentWorkspace]
);
const setSelectedProjects = useCallback(
(selected: Project[]) => {
setSelectedProjectsState(selected);
saveSelectedProjects(selected);
},
[saveSelectedProjects]
);
const toggleProject = useCallback(
(project: Project) => {
setSelectedProjectsState((prev) => {
const isSelected = prev.some((p) => p.id === project.id);
let newSelection: Project[];
if (isSelected) {
// Don't allow deselecting if it's the only one
if (prev.length <= 1) {
return prev;
}
newSelection = prev.filter((p) => p.id !== project.id);
} else {
newSelection = [...prev, project];
}
saveSelectedProjects(newSelection);
return newSelection;
});
},
[saveSelectedProjects]
);
const selectAllProjects = useCallback(() => {
setSelectedProjectsState(projects);
saveSelectedProjects(projects);
}, [projects, saveSelectedProjects]);
const clearProjectSelection = useCallback(() => {
if (projects.length > 0) {
const firstProject = [projects[0]];
setSelectedProjectsState(firstProject);
saveSelectedProjects(firstProject);
}
}, [projects, saveSelectedProjects]);
// ============================================================================
// Check if user is admin in a specific workspace
// ============================================================================
const isWorkspaceAdmin = useCallback(async (workspaceId: string): Promise<boolean> => {
// Demo mode - user is admin
if (isDemoMode || workspaceId === DEMO_WORKSPACE_ID) {
return true;
}
// Check if this is the current workspace
if (currentWorkspace?.id === workspaceId) {
return isAdmin;
}
// Load permissions for the target workspace
try {
const me = await authApi.me();
const membersResponse = await membersApi.list(workspaceId, { size: 100 });
const member = membersResponse.items.find((m) => m.user.id === me.id);
if (!member) {
// Check if user is creator (members list is empty or this is first member)
if (membersResponse.items.length === 0) {
return true;
}
return false;
}
// Check if has admin_full permission
return member.permissions.some((p) => p.key === "admin_full");
} catch (err) {
console.error("Failed to check workspace permissions:", err);
return false;
}
}, [currentWorkspace, isAdmin, isDemoMode]);
// ============================================================================
// Create Workspace
// ============================================================================
const createWorkspace = useCallback(async (name: string, avatarFile?: File | null): Promise<Workspace> => {
const workspace = await workspacesApi.create({ name, avatar_file: avatarFile });
setWorkspaces((prev) => [...prev, workspace]);
return workspace;
}, []);
// ============================================================================
// Effects
// ============================================================================
// Initial load
useEffect(() => {
loadWorkspaces();
}, [loadWorkspaces]);
// Load projects when workspace changes
useEffect(() => {
if (currentWorkspace) {
loadProjectsAndPermissions(currentWorkspace.id);
}
}, [currentWorkspace?.id, loadProjectsAndPermissions]);
// ============================================================================
// Render
// ============================================================================
return (
<WorkspaceContext.Provider
value={{
// Workspaces
workspaces,
currentWorkspace,
setCurrentWorkspace,
// Projects
projects,
selectedProjects,
currentProject,
setSelectedProjects,
toggleProject,
selectAllProjects,
clearProjectSelection,
getProjectFilterId,
allProjectsSelected,
// Demo mode
isDemoMode,
// Permissions
permissions,
hasPermission,
isAdmin,
isWorkspaceAdmin,
hasAnyAnalyticsPermission,
canViewSubscriptions,
canViewAnalytics,
// Loading states
isLoading,
isLoadingProjects,
error,
// Actions
refreshWorkspaces: loadWorkspaces,
refreshProjects: () =>
currentWorkspace
? loadProjectsAndPermissions(currentWorkspace.id)
: Promise.resolve(),
createWorkspace,
}}
>
{children}
</WorkspaceContext.Provider>
);
};