FlowAPI / FlowAPI.Application /Services /GraphService.cs
danylokhodus's picture
feat: optimize canvas load and save using lazy loading and delta upserts
1b321bd
Raw
History Blame Contribute Delete
23.2 kB
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();
}
}
}