Spaces:
Running
Running
| using FlowAPI.Application.DTOs.Graph; | |
| using FlowAPI.Application.Interfaces; | |
| using FlowAPI.Domain.Entities; | |
| using System; | |
| using System.Collections.Generic; | |
| using System.Linq; | |
| using System.Text.Json; | |
| using System.Threading.Tasks; | |
| namespace FlowAPI.Application.Services | |
| { | |
| public class GraphService : IGraphService | |
| { | |
| private readonly IGraphRepository _repo; | |
| private readonly ITaskNodeRepository _nodeRepo; | |
| private readonly IEdgeRepository _edgeRepo; | |
| private readonly IAchievementService _achievementService; | |
| private readonly ISharedGraphRepository _sharedGraphRepo; | |
| private readonly IUserRepository _userRepo; | |
| private readonly ICustomTemplateRepository _customTemplateRepo; | |
| public GraphService( | |
| IGraphRepository repo, | |
| ITaskNodeRepository nodeRepo, | |
| IEdgeRepository edgeRepo, | |
| IAchievementService achievementService, | |
| ISharedGraphRepository sharedGraphRepo, | |
| IUserRepository userRepo, | |
| ICustomTemplateRepository customTemplateRepo) | |
| { | |
| _repo = repo; | |
| _nodeRepo = nodeRepo; | |
| _edgeRepo = edgeRepo; | |
| _achievementService = achievementService; | |
| _sharedGraphRepo = sharedGraphRepo; | |
| _userRepo = userRepo; | |
| _customTemplateRepo = customTemplateRepo; | |
| } | |
| public async Task<IEnumerable<GraphResponseDto>> GetAllByUserIdAsync(Guid userId) | |
| { | |
| // Owned graphs | |
| var ownedGraphs = await _repo.GetAllByUserIdAsync(userId); | |
| var ownedDtos = ownedGraphs.Select(g => | |
| { | |
| var dto = MapToDto(g); | |
| dto.IsShared = false; | |
| dto.Permission = "Owner"; | |
| return dto; | |
| }).ToList(); | |
| // Shared graphs | |
| var sharedShares = await _sharedGraphRepo.GetSharedGraphsByUserIdAsync(userId); | |
| var sharedDtos = sharedShares.Select(s => | |
| { | |
| var dto = MapToDto(s.Graph); | |
| dto.IsShared = true; | |
| dto.Permission = s.Permission; | |
| return dto; | |
| }).ToList(); | |
| return ownedDtos.Concat(sharedDtos); | |
| } | |
| public async Task<GraphResponseDto?> GetByIdAsync(Guid id, Guid currentUserId) | |
| { | |
| var graph = await _repo.GetByIdAsync(id); | |
| if (graph is null) return null; | |
| if (graph.UserId == currentUserId) | |
| { | |
| var dto = MapToDto(graph); | |
| dto.IsShared = false; | |
| dto.Permission = "Owner"; | |
| return dto; | |
| } | |
| var shared = await _sharedGraphRepo.GetShareAsync(id, currentUserId); | |
| if (shared is not null) | |
| { | |
| var dto = MapToDto(graph); | |
| dto.IsShared = true; | |
| dto.Permission = shared.Permission; | |
| return dto; | |
| } | |
| return null; | |
| } | |
| public async Task<FullGraphResponseDto?> GetFullByIdAsync(Guid id, Guid currentUserId) | |
| { | |
| var graph = await _repo.GetByIdWithDetailsAsync(id); | |
| if (graph is null) return null; | |
| string permission = "Owner"; | |
| bool isShared = false; | |
| if (graph.UserId != currentUserId) | |
| { | |
| var shared = await _sharedGraphRepo.GetShareAsync(id, currentUserId); | |
| if (shared is null) return null; | |
| permission = shared.Permission; | |
| isShared = true; | |
| } | |
| return new FullGraphResponseDto | |
| { | |
| Id = graph.Id, | |
| UserId = graph.UserId, | |
| Title = graph.Title, | |
| Description = graph.Description, | |
| CreatedAt = graph.CreatedAt, | |
| IsShared = isShared, | |
| Permission = permission, | |
| Nodes = graph.Nodes.Select(n => | |
| { | |
| bool hasLarge = n.Data != null && n.Data.Length > 2000; | |
| return new NodeDto | |
| { | |
| Id = n.Id, | |
| Label = n.Label, | |
| PosX = n.PosX, | |
| PosY = n.PosY, | |
| Type = n.Type, | |
| State = (int)n.State, | |
| Decision = n.Decision, | |
| Description = n.Description, | |
| Color = n.Color, | |
| IsPinned = n.IsPinned, | |
| Width = n.Width, | |
| Height = n.Height, | |
| Data = hasLarge ? null : n.Data, | |
| HasLargeData = hasLarge | |
| }; | |
| }).ToList(), | |
| Edges = graph.Edges.Select(e => new EdgeDto | |
| { | |
| Id = e.Id, | |
| FromNodeId = e.FromNodeId, | |
| ToNodeId = e.ToNodeId, | |
| Condition = e.Condition.ToString() | |
| }).ToList() | |
| }; | |
| } | |
| public async Task<GraphResponseDto> CreateAsync(CreateGraphDto dto, Guid currentUserId) | |
| { | |
| var user = await _userRepo.GetByIdAsync(currentUserId); | |
| if (user == null) throw new Exception("User not found."); | |
| var tier = user.SubscriptionTier?.Trim(); | |
| bool isFree = string.IsNullOrEmpty(tier) || tier == "Free"; | |
| if (isFree) | |
| { | |
| var ownedGraphsCount = (await _repo.GetAllByUserIdAsync(currentUserId)).Count(); | |
| if (ownedGraphsCount >= 3) | |
| { | |
| throw new InvalidOperationException("Free tier is limited to 3 canvases."); | |
| } | |
| } | |
| var graph = new Graph | |
| { | |
| Id = Guid.NewGuid(), | |
| UserId = currentUserId, // Secure: always force authenticated user ID | |
| Title = dto.Title, | |
| Description = dto.Description, | |
| CreatedAt = DateTime.UtcNow | |
| }; | |
| if (!string.IsNullOrEmpty(dto.TemplateKey)) | |
| { | |
| // Try to load system template first | |
| var (nodes, edges) = TemplateLibrary.GetTemplateContent(dto.TemplateKey, graph.Id); | |
| // If not found in system templates, check custom templates in DB | |
| if (nodes.Count == 0 && Guid.TryParse(dto.TemplateKey, out var customTemplateId)) | |
| { | |
| var customTemplate = await _customTemplateRepo.GetByIdAsync(customTemplateId); | |
| if (customTemplate != null && (customTemplate.UserId == currentUserId || user.SubscriptionTier == "Ultra")) | |
| { | |
| try | |
| { | |
| var data = JsonSerializer.Deserialize<CustomTemplateContentDto>(customTemplate.ContentJson); | |
| if (data != null) | |
| { | |
| var idMapping = new Dictionary<Guid, Guid>(); | |
| foreach (var nodeDto in data.Nodes) | |
| { | |
| var newId = Guid.NewGuid(); | |
| idMapping[nodeDto.Id] = newId; | |
| var node = new TaskNode | |
| { | |
| Id = newId, | |
| GraphId = graph.Id, | |
| Label = nodeDto.Label, | |
| PosX = nodeDto.PosX, | |
| PosY = nodeDto.PosY, | |
| Type = nodeDto.Type, | |
| State = FlowAPI.Domain.Enums.NodeState.Pending, | |
| CreatedAt = DateTime.UtcNow, | |
| Description = nodeDto.Description, | |
| Color = nodeDto.Color, | |
| IsPinned = nodeDto.IsPinned, | |
| Width = nodeDto.Width, | |
| Height = nodeDto.Height, | |
| Data = nodeDto.Data, | |
| Decision = nodeDto.Decision | |
| }; | |
| nodes.Add(node); | |
| } | |
| foreach (var edgeDto in data.Edges) | |
| { | |
| if (idMapping.TryGetValue(edgeDto.FromNodeId, out var fromId) && | |
| idMapping.TryGetValue(edgeDto.ToNodeId, out var toId)) | |
| { | |
| edges.Add(new Edge | |
| { | |
| Id = Guid.NewGuid(), | |
| GraphId = graph.Id, | |
| FromNodeId = fromId, | |
| ToNodeId = toId, | |
| Condition = edgeDto.Condition | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| System.Console.WriteLine($"[GraphService] Failed to load custom template: {ex.Message}"); | |
| } | |
| } | |
| } | |
| if (nodes.Count > 0) | |
| { | |
| graph.Nodes = nodes; | |
| graph.Edges = edges; | |
| if (string.IsNullOrEmpty(dto.Description) || dto.Description == "New strategy path...") | |
| { | |
| graph.Description = $"Побудовано за шаблоном"; | |
| } | |
| } | |
| } | |
| var created = await _repo.CreateAsync(graph); | |
| await _achievementService.ProcessEventAsync(currentUserId, "GraphCreated"); | |
| if (graph.Nodes.Count > 0) | |
| { | |
| await _achievementService.ProcessEventAsync(currentUserId, "NodeCreated", graph.Nodes.Count); | |
| } | |
| if (graph.Edges.Count > 0) | |
| { | |
| await _achievementService.ProcessEventAsync(currentUserId, "EdgeCreated", graph.Edges.Count); | |
| } | |
| var result = MapToDto(created); | |
| result.IsShared = false; | |
| result.Permission = "Owner"; | |
| return result; | |
| } | |
| public async Task<GraphResponseDto?> UpdateAsync(Guid id, UpdateGraphDto dto, Guid currentUserId) | |
| { | |
| var graph = await _repo.GetByIdAsync(id); | |
| if (graph is null) return null; | |
| // Secure: only allowed for owner or edit shared users | |
| if (graph.UserId != currentUserId) | |
| { | |
| var shared = await _sharedGraphRepo.GetShareAsync(id, currentUserId); | |
| if (shared is null || shared.Permission != "Edit") return null; | |
| } | |
| if (dto.Title is not null) graph.Title = dto.Title; | |
| if (dto.Description is not null) graph.Description = dto.Description; | |
| var updated = await _repo.UpdateAsync(graph); | |
| var result = MapToDto(updated); | |
| if (graph.UserId != currentUserId) | |
| { | |
| result.IsShared = true; | |
| result.Permission = "Edit"; | |
| } | |
| return result; | |
| } | |
| public async Task<bool> DeleteAsync(Guid id, Guid currentUserId) | |
| { | |
| var graph = await _repo.GetByIdAsync(id); | |
| if (graph == null) return false; | |
| // Secure: only owner can delete the graph | |
| if (graph.UserId != currentUserId) return false; | |
| var deleted = await _repo.DeleteAsync(id); | |
| if (deleted) | |
| { | |
| await _achievementService.ProcessEventAsync(graph.UserId, "GraphDeleted"); | |
| } | |
| return deleted; | |
| } | |
| public async Task<bool> SaveFullGraphAsync(Guid id, FullGraphResponseDto dto, Guid currentUserId) | |
| { | |
| var graph = await _repo.GetByIdWithDetailsAsync(id); | |
| if (graph is null) return false; | |
| var user = await _userRepo.GetByIdAsync(currentUserId); | |
| if (user == null) throw new Exception("User not found."); | |
| var tier = user.SubscriptionTier?.Trim(); | |
| bool isFree = string.IsNullOrEmpty(tier) || tier == "Free"; | |
| if (isFree) | |
| { | |
| if (dto.Nodes.Count > 200) | |
| { | |
| throw new InvalidOperationException("Free tier is limited to 200 nodes per canvas."); | |
| } | |
| } | |
| // Secure: only allowed for owner or shared users with Edit permission | |
| if (graph.UserId != currentUserId) | |
| { | |
| var shared = await _sharedGraphRepo.GetShareAsync(id, currentUserId); | |
| if (shared is null || shared.Permission != "Edit") return false; | |
| } | |
| // Map DTOs to Domain Entities | |
| var mappedNodes = dto.Nodes.Select(nodeDto => new TaskNode | |
| { | |
| Id = nodeDto.Id == Guid.Empty ? Guid.NewGuid() : nodeDto.Id, | |
| GraphId = id, | |
| Label = nodeDto.Label, | |
| PosX = nodeDto.PosX, | |
| PosY = nodeDto.PosY, | |
| Type = nodeDto.Type, | |
| Decision = nodeDto.Decision, | |
| State = (FlowAPI.Domain.Enums.NodeState)nodeDto.State, | |
| Description = nodeDto.Description, | |
| Color = nodeDto.Color, | |
| IsPinned = nodeDto.IsPinned, | |
| Width = nodeDto.Width, | |
| Height = nodeDto.Height, | |
| Data = nodeDto.Data | |
| }).ToList(); | |
| var mappedEdges = dto.Edges.Select(edgeDto => new Edge | |
| { | |
| Id = edgeDto.Id == Guid.Empty ? Guid.NewGuid() : edgeDto.Id, | |
| GraphId = id, | |
| FromNodeId = edgeDto.FromNodeId, | |
| ToNodeId = edgeDto.ToNodeId, | |
| Condition = edgeDto.Condition ?? "True" | |
| }).ToList(); | |
| // Calculate deltas for achievements | |
| var oldNodes = graph.Nodes ?? new List<TaskNode>(); | |
| var oldEdges = graph.Edges ?? new List<Edge>(); | |
| int newlyCreatedNodes = mappedNodes.Count(n => !oldNodes.Any(o => o.Id == n.Id)); | |
| int newlyCreatedEdges = mappedEdges.Count(e => !oldEdges.Any(o => o.Id == e.Id)); | |
| int newlyCompletedNodes = mappedNodes.Count(n => | |
| n.State == FlowAPI.Domain.Enums.NodeState.Done && | |
| (!oldNodes.Any(o => o.Id == n.Id) || oldNodes.First(o => o.Id == n.Id).State != FlowAPI.Domain.Enums.NodeState.Done) | |
| ); | |
| // Perform robust Upsert | |
| var success = await _repo.UpsertFullGraphAsync(id, mappedNodes, mappedEdges); | |
| if (!success) return false; | |
| // Trigger Achievements based on deltas | |
| if (newlyCreatedNodes > 0) await _achievementService.ProcessEventAsync(graph.UserId, "NodeCreated", newlyCreatedNodes); | |
| if (newlyCreatedEdges > 0) await _achievementService.ProcessEventAsync(graph.UserId, "EdgeCreated", newlyCreatedEdges); | |
| if (newlyCompletedNodes > 0) await _achievementService.ProcessEventAsync(graph.UserId, "TaskCompleted", newlyCompletedNodes); | |
| return true; | |
| } | |
| public async Task<GraphResponseDto?> ShareGraphAsync(Guid graphId, Guid ownerId, ShareGraphRequestDto dto) | |
| { | |
| var graph = await _repo.GetByIdAsync(graphId); | |
| if (graph is null || graph.UserId != ownerId) return null; | |
| var owner = await _userRepo.GetByIdAsync(ownerId); | |
| if (owner == null) throw new Exception("Owner user not found."); | |
| var targetUser = await _userRepo.GetByEmailAsync(dto.Email.ToLower().Trim()); | |
| if (targetUser is null || targetUser.Id == ownerId) return null; | |
| var existing = await _sharedGraphRepo.GetShareAsync(graphId, targetUser.Id); | |
| if (existing is not null) | |
| { | |
| existing.Permission = dto.Permission; | |
| await _sharedGraphRepo.UpdateAsync(existing); | |
| } | |
| else | |
| { | |
| // Enforce sharing tier limits based on owner's tier | |
| var shares = await _sharedGraphRepo.GetSharesForGraphAsync(graphId); | |
| int currentSharedCount = shares?.Count() ?? 0; | |
| var ownerTier = owner.SubscriptionTier?.Trim(); | |
| bool ownerIsFree = string.IsNullOrEmpty(ownerTier) || ownerTier == "Free"; | |
| if (ownerIsFree && currentSharedCount >= 1) | |
| { | |
| throw new InvalidOperationException("Free tier is limited to 2 people on a board (1 guest)."); | |
| } | |
| else if (ownerTier == "Pro" && currentSharedCount >= 9) | |
| { | |
| throw new InvalidOperationException("Pro tier is limited to 10 people on a board (9 guests)."); | |
| } | |
| var share = new SharedGraph | |
| { | |
| Id = Guid.NewGuid(), | |
| GraphId = graphId, | |
| SharedWithUserId = targetUser.Id, | |
| Permission = dto.Permission, | |
| CreatedAt = DateTime.UtcNow | |
| }; | |
| await _sharedGraphRepo.CreateAsync(share); | |
| } | |
| var res = MapToDto(graph); | |
| res.IsShared = true; | |
| res.Permission = dto.Permission; | |
| return res; | |
| } | |
| public async Task<IEnumerable<SharedUserResponseDto>> GetSharedUsersAsync(Guid graphId, Guid ownerId) | |
| { | |
| var graph = await _repo.GetByIdAsync(graphId); | |
| if (graph is null) return Enumerable.Empty<SharedUserResponseDto>(); | |
| // Allow access to shares list if caller is the owner or a guest with access | |
| if (graph.UserId != ownerId) | |
| { | |
| var shareAccess = await _sharedGraphRepo.GetShareAsync(graphId, ownerId); | |
| if (shareAccess is null) return Enumerable.Empty<SharedUserResponseDto>(); | |
| } | |
| var result = new List<SharedUserResponseDto>(); | |
| // Get owner details | |
| var owner = await _userRepo.GetByIdAsync(graph.UserId); | |
| if (owner is not null) | |
| { | |
| result.Add(new SharedUserResponseDto | |
| { | |
| Id = Guid.Empty, | |
| SharedWithUserId = owner.Id, | |
| Email = owner.Email, | |
| DisplayName = owner.DisplayName, | |
| Permission = "Owner" | |
| }); | |
| } | |
| // Get shared users | |
| var shares = await _sharedGraphRepo.GetSharesForGraphAsync(graphId); | |
| if (shares is not null) | |
| { | |
| result.AddRange(shares.Select(s => new SharedUserResponseDto | |
| { | |
| Id = s.Id, | |
| SharedWithUserId = s.SharedWithUserId, | |
| Email = s.SharedWithUser?.Email ?? string.Empty, | |
| DisplayName = s.SharedWithUser?.DisplayName ?? string.Empty, | |
| Permission = s.Permission | |
| })); | |
| } | |
| return result; | |
| } | |
| public async Task<bool> RevokeShareAsync(Guid graphId, Guid ownerId, Guid shareId) | |
| { | |
| var graph = await _repo.GetByIdAsync(graphId); | |
| if (graph is null || graph.UserId != ownerId) return false; | |
| var share = await _sharedGraphRepo.GetByIdAsync(shareId); | |
| if (share is null || share.GraphId != graphId) return false; | |
| return await _sharedGraphRepo.DeleteAsync(shareId); | |
| } | |
| private static GraphResponseDto MapToDto(Graph graph) => new() | |
| { | |
| Id = graph.Id, | |
| UserId = graph.UserId, | |
| Title = graph.Title, | |
| Description = graph.Description, | |
| CreatedAt = graph.CreatedAt | |
| }; | |
| public async Task<CustomTemplate> SaveAsTemplateAsync(Guid graphId, Guid userId, string title, string description) | |
| { | |
| var user = await _userRepo.GetByIdAsync(userId); | |
| if (user == null) throw new Exception("User not found."); | |
| if (user.SubscriptionTier != "Ultra") | |
| { | |
| throw new InvalidOperationException("Creating custom templates is an Ultra tier feature."); | |
| } | |
| var graph = await _repo.GetByIdWithDetailsAsync(graphId); | |
| if (graph == null || graph.UserId != userId) | |
| { | |
| throw new Exception("Graph not found or you are not the owner."); | |
| } | |
| // Map nodes and edges to DTOs for serialization | |
| var nodes = graph.Nodes.Select(n => new NodeDto | |
| { | |
| Id = n.Id, | |
| Label = n.Label, | |
| PosX = n.PosX, | |
| PosY = n.PosY, | |
| Type = n.Type, | |
| State = (int)n.State, | |
| Decision = n.Decision, | |
| Description = n.Description, | |
| Color = n.Color, | |
| IsPinned = n.IsPinned, | |
| Width = n.Width, | |
| Height = n.Height, | |
| Data = n.Data | |
| }).ToList(); | |
| var edges = graph.Edges.Select(e => new EdgeDto | |
| { | |
| Id = e.Id, | |
| FromNodeId = e.FromNodeId, | |
| ToNodeId = e.ToNodeId, | |
| Condition = e.Condition | |
| }).ToList(); | |
| var contentJson = JsonSerializer.Serialize(new CustomTemplateContentDto | |
| { | |
| Nodes = nodes, | |
| Edges = edges | |
| }); | |
| var template = new CustomTemplate | |
| { | |
| Id = Guid.NewGuid(), | |
| UserId = userId, | |
| Title = title, | |
| Description = description, | |
| Category = "Custom", | |
| Icon = "BookOpen", | |
| ContentJson = contentJson, | |
| CreatedAt = DateTime.UtcNow | |
| }; | |
| return await _customTemplateRepo.CreateAsync(template); | |
| } | |
| public async Task<IEnumerable<CustomTemplate>> GetCustomTemplatesAsync(Guid userId) | |
| { | |
| return await _customTemplateRepo.GetByUserIdAsync(userId); | |
| } | |
| private class CustomTemplateContentDto | |
| { | |
| public List<NodeDto> Nodes { get; set; } = new(); | |
| public List<EdgeDto> Edges { get; set; } = new(); | |
| } | |
| } | |
| } | |