commit 4f1be2c3dbcf5c3149a99da0f5b2f9b099192ad1 Author: Vitali Semianiaka Date: Fri Dec 26 16:40:32 2025 +0300 changes diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc78471 --- /dev/null +++ b/.gitignore @@ -0,0 +1,484 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/Controllers/JournalController.cs b/Controllers/JournalController.cs new file mode 100644 index 0000000..ba38bec --- /dev/null +++ b/Controllers/JournalController.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Mvc; +using public_valetax.DTOs; +using public_valetax.Exceptions; +using public_valetax.Repositories; + +namespace public_valetax.Controllers +{ + [ApiController] + [Route("api.[controller]")] + public class JournalController(IJournalRepository _journalRepository) : ControllerBase + { + [HttpPost("getRange")] + public async Task> GetRange([FromQuery] int skip, [FromQuery] int take, [FromBody] VJournalFilter filter) + { + var result = await _journalRepository.GetJournalEntriesRangeAsync(skip, take, filter); + return Ok(result); + } + + [HttpPost("getSingle")] + public async Task> GetSingle([FromQuery] long id) + { + var result = await _journalRepository.GetJournalEntryAsync(id); + if (result == null) + { + return NotFound(); + } + return Ok(result); + } + + [HttpPost("simulateError")] + public void SimulateError([FromQuery] bool isSecureException, [FromBody] Dictionary someBody) + { + if (isSecureException) + { + throw new SecureException("Some secure error happened"); + } + else + { + throw new InvalidOperationException("Some error happened"); + } + } + } +} \ No newline at end of file diff --git a/Controllers/NodeController.cs b/Controllers/NodeController.cs new file mode 100644 index 0000000..27a0b4c --- /dev/null +++ b/Controllers/NodeController.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.Mvc; +using public_valetax.Repositories; +using public_valetax.Exceptions; + +namespace public_valetax.Controllers +{ + [ApiController] + [Route("api.[controller]")] + public class NodeController(ITreeRepository _treeRepository) : ControllerBase + { + [HttpPost("create")] + public async Task Create( + [FromQuery] string treeName, + [FromQuery] long? parentNodeId, + [FromQuery] string nodeName) + { + try + { + // Get or create the tree + var tree = await _treeRepository.GetTreeByNameAsync(treeName); + if (tree == null) + { + tree = await _treeRepository.CreateTreeAsync(treeName); + } + + // Create the node + var node = await _treeRepository.CreateNodeAsync(tree.Id, parentNodeId, nodeName); + + return Ok(); + } + catch (SecureException) + { + // Re-throw secure exceptions to be handled by middleware + throw; + } + catch (Exception ex) + { + // Wrap other exceptions in SecureException to trigger proper error handling + throw new SecureException(ex.Message, ex); + } + } + + [HttpPost("delete")] + public async Task Delete([FromQuery] long nodeId) + { + try + { + await _treeRepository.DeleteNodeAsync(nodeId); + return Ok(); + } + catch (SecureException) + { + // Re-throw secure exceptions to be handled by middleware + throw; + } + catch (Exception ex) + { + // Wrap other exceptions in SecureException to trigger proper error handling + throw new SecureException(ex.Message, ex); + } + } + + [HttpPost("rename")] + public async Task Rename([FromQuery] long nodeId, [FromQuery] string newNodeName) + { + try + { + await _treeRepository.RenameNodeAsync(nodeId, newNodeName); + return Ok(); + } + catch (SecureException) + { + // Re-throw secure exceptions to be handled by middleware + throw; + } + catch (Exception ex) + { + // Wrap other exceptions in SecureException to trigger proper error handling + throw new SecureException(ex.Message, ex); + } + } + } +} \ No newline at end of file diff --git a/Controllers/PartnerController.cs b/Controllers/PartnerController.cs new file mode 100644 index 0000000..6b839cb --- /dev/null +++ b/Controllers/PartnerController.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc; +using public_valetax.DTOs; + +namespace public_valetax.Controllers +{ + [ApiController] + [Route("api.[controller]")] + public class PartnerController : ControllerBase + { + [HttpPost("rememberMe")] + public ActionResult RememberMe([FromQuery] string code) + { + // This is a simplified implementation + // In a real application, you would validate the code and generate a proper JWT token + var token = $"generated_token_for_code_{code}"; + + return Ok(new TokenInfo { Token = token }); + } + } +} \ No newline at end of file diff --git a/Controllers/TreeController.cs b/Controllers/TreeController.cs new file mode 100644 index 0000000..3a9dacd --- /dev/null +++ b/Controllers/TreeController.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; +using public_valetax.DTOs; +using public_valetax.Repositories; + +namespace public_valetax.Controllers +{ + [ApiController] + [Route("api.[controller]")] + public class TreeController(ITreeRepository _treeRepository) : ControllerBase + { + [HttpPost("get")] + public async Task> Get([FromQuery] string treeName) + { + var tree = await _treeRepository.GetTreeStructureAsync(treeName); + + // If tree doesn't exist, create it + if (tree == null) + { + // Create the tree (implementation would depend on your requirements) + // For now, we'll just return an empty structure + tree = new MNode + { + Id = 0, + Name = treeName, + Children = [] + }; + } + + return Ok(tree); + } + } +} \ No newline at end of file diff --git a/DTOs/MJournal.cs b/DTOs/MJournal.cs new file mode 100644 index 0000000..9862ff7 --- /dev/null +++ b/DTOs/MJournal.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace public_valetax.DTOs +{ + public class MJournal + { + [JsonPropertyName("text")] + public string Text { get; set; } = string.Empty; + + [JsonPropertyName("id")] + public long Id { get; set; } + + [JsonPropertyName("eventId")] + public long EventId { get; set; } + + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } + } +} \ No newline at end of file diff --git a/DTOs/MJournalInfo.cs b/DTOs/MJournalInfo.cs new file mode 100644 index 0000000..1e04f67 --- /dev/null +++ b/DTOs/MJournalInfo.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace public_valetax.DTOs +{ + public class MJournalInfo + { + [JsonPropertyName("id")] + public long Id { get; set; } + + [JsonPropertyName("eventId")] + public long EventId { get; set; } + + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } + } +} \ No newline at end of file diff --git a/DTOs/MNode.cs b/DTOs/MNode.cs new file mode 100644 index 0000000..da0d115 --- /dev/null +++ b/DTOs/MNode.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace public_valetax.DTOs +{ + public class MNode + { + [JsonPropertyName("id")] + public long Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("children")] + public ICollection Children { get; set; } = []; + } +} \ No newline at end of file diff --git a/DTOs/MRange_MJournalInfo.cs b/DTOs/MRange_MJournalInfo.cs new file mode 100644 index 0000000..76bc3a5 --- /dev/null +++ b/DTOs/MRange_MJournalInfo.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace public_valetax.DTOs +{ + public class MRange_MJournalInfo + { + [JsonPropertyName("skip")] + public int Skip { get; set; } + + [JsonPropertyName("count")] + public int Count { get; set; } + + [JsonPropertyName("items")] + public ICollection Items { get; set; } = []; + } +} \ No newline at end of file diff --git a/DTOs/TokenInfo.cs b/DTOs/TokenInfo.cs new file mode 100644 index 0000000..12b7bb0 --- /dev/null +++ b/DTOs/TokenInfo.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace public_valetax.DTOs +{ + public class TokenInfo + { + [JsonPropertyName("token")] + public string Token { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/DTOs/VJournalFilter.cs b/DTOs/VJournalFilter.cs new file mode 100644 index 0000000..c12700a --- /dev/null +++ b/DTOs/VJournalFilter.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace public_valetax.DTOs +{ + public class VJournalFilter + { + [JsonPropertyName("from")] + public DateTime? From { get; set; } + + [JsonPropertyName("to")] + public DateTime? To { get; set; } + + [JsonPropertyName("search")] + public string Search { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Data/ApplicationDbContext.cs b/Data/ApplicationDbContext.cs new file mode 100644 index 0000000..2978a7f --- /dev/null +++ b/Data/ApplicationDbContext.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore; +using public_valetax.Models; + +namespace public_valetax.Data +{ + public class ApplicationDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet Trees { get; set; } = null!; + public DbSet Nodes { get; set; } = null!; + public DbSet JournalEntries { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Configure Tree entity + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired().HasMaxLength(255); + entity.HasIndex(e => e.Name).IsUnique(); + }); + + // Configure Node entity + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired().HasMaxLength(255); + + // Self-referencing foreign key for parent-child relationship + entity.HasOne(n => n.Parent) + .WithMany(n => n.Children) + .HasForeignKey(n => n.ParentId) + .OnDelete(DeleteBehavior.Cascade); + + // Foreign key to Tree + entity.HasOne(n => n.Tree) + .WithMany(t => t.Nodes) + .HasForeignKey(n => n.TreeId) + .OnDelete(DeleteBehavior.Cascade); + + // Ensure unique node names within the same tree + entity.HasIndex(n => new { n.TreeId, n.Name }).IsUnique(); + }); + + // Configure JournalEntry entity + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.ExceptionType).IsRequired().HasMaxLength(255); + entity.Property(e => e.Message).IsRequired(); + entity.HasIndex(e => e.EventId).IsUnique(); + }); + } + } +} \ No newline at end of file diff --git a/Exceptions/SecureException.cs b/Exceptions/SecureException.cs new file mode 100644 index 0000000..4f35c1b --- /dev/null +++ b/Exceptions/SecureException.cs @@ -0,0 +1,11 @@ +namespace public_valetax.Exceptions +{ + public class SecureException : Exception + { + public SecureException() : base() { } + + public SecureException(string message) : base(message) { } + + public SecureException(string message, Exception innerException) : base(message, innerException) { } + } +} \ No newline at end of file diff --git a/Middleware/ExceptionHandlingMiddleware.cs b/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..f052579 --- /dev/null +++ b/Middleware/ExceptionHandlingMiddleware.cs @@ -0,0 +1,74 @@ +using System.Text.Json; +using public_valetax.Exceptions; +using public_valetax.Repositories; + +namespace public_valetax.Middleware +{ + public class ExceptionHandlingMiddleware(RequestDelegate _next, ILogger _logger) + { + public async Task InvokeAsync(HttpContext context, IJournalRepository journalRepository) + { + try + { + if (context.Request.ContentLength > 0) + { + context.Request.EnableBuffering(); + } + await _next(context); + } + catch (Exception ex) + { + _logger.LogError(ex, "An unhandled exception occurred"); + + // Generate event ID + var eventId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + // Capture request details + var queryParameters = context.Request.QueryString.ToString(); + string? bodyParameters = null; + + // Try to capture body parameters (be careful not to consume the stream multiple times) + if (context.Request.ContentLength > 0) + { + context.Request.Body.Position = 0; // Reset for next middleware + using var reader = new StreamReader(context.Request.Body, leaveOpen: true); + bodyParameters = await reader.ReadToEndAsync(); + context.Request.Body.Position = 0; // Reset for next middleware + } + + // Log to journal + await journalRepository.CreateJournalEntryAsync( + eventId, + ex.GetType().Name, + ex.Message, + queryParameters, + bodyParameters, + ex.StackTrace); + + // Handle specific exception types + await HandleExceptionAsync(context, ex, eventId); + } + } + + private static async Task HandleExceptionAsync(HttpContext context, Exception exception, long eventId) + { + var response = new + { + type = exception is SecureException ? "Secure" : "Exception", + id = eventId.ToString(), + data = new + { + message = exception is SecureException + ? exception.Message + : $"Internal server error ID = {eventId}" + } + }; + + context.Response.ContentType = "application/json"; + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + + var jsonResponse = JsonSerializer.Serialize(response); + await context.Response.WriteAsync(jsonResponse); + } + } +} \ No newline at end of file diff --git a/Middleware/ExceptionHandlingMiddlewareExtensions.cs b/Middleware/ExceptionHandlingMiddlewareExtensions.cs new file mode 100644 index 0000000..4ab0aba --- /dev/null +++ b/Middleware/ExceptionHandlingMiddlewareExtensions.cs @@ -0,0 +1,10 @@ +namespace public_valetax.Middleware +{ + public static class ExceptionHandlingMiddlewareExtensions + { + public static IApplicationBuilder UseExceptionHandling(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } + } +} \ No newline at end of file diff --git a/Migrations/20251226122935_InitialCreate.Designer.cs b/Migrations/20251226122935_InitialCreate.Designer.cs new file mode 100644 index 0000000..13b84ac --- /dev/null +++ b/Migrations/20251226122935_InitialCreate.Designer.cs @@ -0,0 +1,170 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using public_valetax.Data; + +#nullable disable + +namespace public_valetax.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20251226122935_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("public_valetax.Models.JournalEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BodyParameters") + .HasColumnType("text") + .HasColumnName("body_parameters"); + + b.Property("EventId") + .HasColumnType("bigint") + .HasColumnName("event_id"); + + b.Property("ExceptionType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("exception_type"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("QueryParameters") + .HasColumnType("text") + .HasColumnName("query_parameters"); + + b.Property("StackTrace") + .HasColumnType("text") + .HasColumnName("stack_trace"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.HasKey("Id"); + + b.HasIndex("EventId") + .IsUnique(); + + b.ToTable("journal_entries"); + }); + + modelBuilder.Entity("public_valetax.Models.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("bigint") + .HasColumnName("parent_id"); + + b.Property("TreeId") + .HasColumnType("bigint") + .HasColumnName("tree_id"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("TreeId", "Name") + .IsUnique(); + + b.ToTable("nodes"); + }); + + modelBuilder.Entity("public_valetax.Models.Tree", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("trees"); + }); + + modelBuilder.Entity("public_valetax.Models.Node", b => + { + b.HasOne("public_valetax.Models.Node", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("public_valetax.Models.Tree", "Tree") + .WithMany("Nodes") + .HasForeignKey("TreeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + + b.Navigation("Tree"); + }); + + modelBuilder.Entity("public_valetax.Models.Node", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("public_valetax.Models.Tree", b => + { + b.Navigation("Nodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20251226122935_InitialCreate.cs b/Migrations/20251226122935_InitialCreate.cs new file mode 100644 index 0000000..820e406 --- /dev/null +++ b/Migrations/20251226122935_InitialCreate.cs @@ -0,0 +1,113 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace public_valetax.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "journal_entries", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + event_id = table.Column(type: "bigint", nullable: false), + timestamp = table.Column(type: "timestamp with time zone", nullable: false), + query_parameters = table.Column(type: "text", nullable: true), + body_parameters = table.Column(type: "text", nullable: true), + stack_trace = table.Column(type: "text", nullable: true), + exception_type = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + message = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_journal_entries", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "trees", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + name = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_trees", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "nodes", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + tree_id = table.Column(type: "bigint", nullable: false), + parent_id = table.Column(type: "bigint", nullable: true), + name = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_nodes", x => x.id); + table.ForeignKey( + name: "FK_nodes_nodes_parent_id", + column: x => x.parent_id, + principalTable: "nodes", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_nodes_trees_tree_id", + column: x => x.tree_id, + principalTable: "trees", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_journal_entries_event_id", + table: "journal_entries", + column: "event_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_nodes_parent_id", + table: "nodes", + column: "parent_id"); + + migrationBuilder.CreateIndex( + name: "IX_nodes_tree_id_name", + table: "nodes", + columns: new[] { "tree_id", "name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_trees_name", + table: "trees", + column: "name", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "journal_entries"); + + migrationBuilder.DropTable( + name: "nodes"); + + migrationBuilder.DropTable( + name: "trees"); + } + } +} diff --git a/Migrations/ApplicationDbContextModelSnapshot.cs b/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..ca16c60 --- /dev/null +++ b/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,167 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using public_valetax.Data; + +#nullable disable + +namespace public_valetax.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("public_valetax.Models.JournalEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BodyParameters") + .HasColumnType("text") + .HasColumnName("body_parameters"); + + b.Property("EventId") + .HasColumnType("bigint") + .HasColumnName("event_id"); + + b.Property("ExceptionType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("exception_type"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("QueryParameters") + .HasColumnType("text") + .HasColumnName("query_parameters"); + + b.Property("StackTrace") + .HasColumnType("text") + .HasColumnName("stack_trace"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.HasKey("Id"); + + b.HasIndex("EventId") + .IsUnique(); + + b.ToTable("journal_entries"); + }); + + modelBuilder.Entity("public_valetax.Models.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("bigint") + .HasColumnName("parent_id"); + + b.Property("TreeId") + .HasColumnType("bigint") + .HasColumnName("tree_id"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("TreeId", "Name") + .IsUnique(); + + b.ToTable("nodes"); + }); + + modelBuilder.Entity("public_valetax.Models.Tree", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("trees"); + }); + + modelBuilder.Entity("public_valetax.Models.Node", b => + { + b.HasOne("public_valetax.Models.Node", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("public_valetax.Models.Tree", "Tree") + .WithMany("Nodes") + .HasForeignKey("TreeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + + b.Navigation("Tree"); + }); + + modelBuilder.Entity("public_valetax.Models.Node", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("public_valetax.Models.Tree", b => + { + b.Navigation("Nodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Models/JournalEntry.cs b/Models/JournalEntry.cs new file mode 100644 index 0000000..71119c3 --- /dev/null +++ b/Models/JournalEntry.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace public_valetax.Models +{ + [Table("journal_entries")] + public class JournalEntry + { + [Key] + [Column("id")] + public long Id { get; set; } + + [Required] + [Column("event_id")] + public long EventId { get; set; } + + [Required] + [Column("timestamp")] + public DateTime Timestamp { get; set; } + + [Column("query_parameters")] + public string? QueryParameters { get; set; } + + [Column("body_parameters")] + public string? BodyParameters { get; set; } + + [Column("stack_trace")] + public string? StackTrace { get; set; } + + [Required] + [Column("exception_type")] + public string ExceptionType { get; set; } = string.Empty; + + [Required] + [Column("message")] + public string Message { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Models/Node.cs b/Models/Node.cs new file mode 100644 index 0000000..c7643b8 --- /dev/null +++ b/Models/Node.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace public_valetax.Models +{ + [Table("nodes")] + public class Node + { + [Key] + [Column("id")] + public long Id { get; set; } + + [Required] + [Column("tree_id")] + public long TreeId { get; set; } + + [Column("parent_id")] + public long? ParentId { get; set; } + + [Required] + [Column("name")] + public string Name { get; set; } = string.Empty; + + [Column("created_at")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation properties + [ForeignKey("TreeId")] + public Tree Tree { get; set; } = null!; + + [ForeignKey("ParentId")] + public Node? Parent { get; set; } + + public ICollection Children { get; set; } = []; + } +} \ No newline at end of file diff --git a/Models/Tree.cs b/Models/Tree.cs new file mode 100644 index 0000000..07103b2 --- /dev/null +++ b/Models/Tree.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace public_valetax.Models +{ + [Table("trees")] + public class Tree + { + [Key] + [Column("id")] + public long Id { get; set; } + + [Required] + [Column("name")] + public string Name { get; set; } = string.Empty; + + [Column("created_at")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation property + public ICollection Nodes { get; set; } = []; + } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..a06008c --- /dev/null +++ b/Program.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; +using public_valetax.Data; +using public_valetax.Repositories; +using public_valetax.Middleware; +using Scalar.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddOpenApi(); + +// Add Entity Framework +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); + +// Add repositories +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Add controllers +builder.Services.AddControllers(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); + app.MapScalarApiReference(); +} + +app.UseHttpsRedirection(); + +// Add exception handling middleware +app.UseExceptionHandling(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..8f990fe --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5090", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7153;http://localhost:5090", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f5c764 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Valetax Test Application + +This is an ASP.NET Core 10 application with REST API that implements the requirements from the ValetaxTest.pdf document. + +## Features Implemented + +1. **Tree Node Management** + - Create independent trees with nodes + - Each node belongs to a single tree + - Child nodes belong to the same tree as their parent + - Unique node names within the same parent + - Delete nodes with all their descendants + - Rename nodes + +2. **Exception Journal** + - Track all exceptions during REST API request processing + - Store unique Event ID, Timestamp, query/body parameters, and stack trace + - Custom SecureException handling + +3. **API Endpoints** + - Journal API: getRange, getSingle + - Authentication API: rememberMe (simplified implementation) + - Tree API: get + - Node API: create, delete, rename + +## Database Setup + +The application uses PostgreSQL as the database. To set up the database: + +1. Make sure you have PostgreSQL installed and running +2. Create a database named "valetax" +3. Update the connection string in appsettings.json if needed + +## Running Migrations + +To create and run the database migration, execute the following commands: + +```bash +dotnet ef migrations add InitialCreate +dotnet ef database update +``` + +## Running the Application + +To run the application: + +```bash +dotnet run +``` + +The API will be available at https://localhost:7153 or http://localhost:5090. + +## API Documentation + +The API follows the Swagger specification provided in the requirements. You can access the OpenAPI documentation at `/openapi/v1.json` when running in development mode. \ No newline at end of file diff --git a/Repositories/IJournalRepository.cs b/Repositories/IJournalRepository.cs new file mode 100644 index 0000000..ea4352e --- /dev/null +++ b/Repositories/IJournalRepository.cs @@ -0,0 +1,19 @@ +using public_valetax.Models; +using public_valetax.DTOs; + +namespace public_valetax.Repositories +{ + public interface IJournalRepository + { + Task CreateJournalEntryAsync( + long eventId, + string exceptionType, + string message, + string? queryParameters = null, + string? bodyParameters = null, + string? stackTrace = null); + + Task GetJournalEntryAsync(long id); + Task GetJournalEntriesRangeAsync(int skip, int take, VJournalFilter filter); + } +} \ No newline at end of file diff --git a/Repositories/ITreeRepository.cs b/Repositories/ITreeRepository.cs new file mode 100644 index 0000000..1b1bdce --- /dev/null +++ b/Repositories/ITreeRepository.cs @@ -0,0 +1,16 @@ +using public_valetax.Models; +using public_valetax.DTOs; + +namespace public_valetax.Repositories +{ + public interface ITreeRepository + { + Task GetTreeByNameAsync(string name); + Task CreateTreeAsync(string name); + Task GetNodeByIdAsync(long nodeId); + Task CreateNodeAsync(long treeId, long? parentId, string name); + Task DeleteNodeAsync(long nodeId); + Task RenameNodeAsync(long nodeId, string newName); + Task GetTreeStructureAsync(string treeName); + } +} \ No newline at end of file diff --git a/Repositories/JournalRepository.cs b/Repositories/JournalRepository.cs new file mode 100644 index 0000000..6fb826d --- /dev/null +++ b/Repositories/JournalRepository.cs @@ -0,0 +1,96 @@ +using Microsoft.EntityFrameworkCore; +using public_valetax.Data; +using public_valetax.Models; +using public_valetax.DTOs; + +namespace public_valetax.Repositories +{ + public class JournalRepository(ApplicationDbContext _context) : IJournalRepository + { + public async Task CreateJournalEntryAsync( + long eventId, + string exceptionType, + string message, + string? queryParameters = null, + string? bodyParameters = null, + string? stackTrace = null) + { + var journalEntry = new JournalEntry + { + EventId = eventId, + Timestamp = DateTime.UtcNow, + ExceptionType = exceptionType, + Message = message, + QueryParameters = queryParameters, + BodyParameters = bodyParameters, + StackTrace = stackTrace + }; + + _context.JournalEntries.Add(journalEntry); + await _context.SaveChangesAsync(); + return journalEntry; + } + + public async Task GetJournalEntryAsync(long id) + { + var journalEntry = await _context.JournalEntries.FindAsync(id); + + if (journalEntry == null) + { + return null; + } + + return new MJournal + { + Id = journalEntry.Id, + EventId = journalEntry.EventId, + Text = journalEntry.Message, + CreatedAt = journalEntry.Timestamp + }; + } + + public async Task GetJournalEntriesRangeAsync(int skip, int take, VJournalFilter filter) + { + var query = _context.JournalEntries.AsQueryable(); + + // Apply filters if provided + if (filter.From.HasValue) + { + query = query.Where(j => j.Timestamp >= filter.From.Value); + } + + if (filter.To.HasValue) + { + query = query.Where(j => j.Timestamp <= filter.To.Value); + } + + if (!string.IsNullOrEmpty(filter.Search)) + { + query = query.Where(j => j.Message.Contains(filter.Search) || j.ExceptionType.Contains(filter.Search)); + } + + // Get total count before pagination + var totalCount = await query.CountAsync(); + + // Apply pagination + var journalEntries = await query + .OrderByDescending(j => j.Timestamp) + .Skip(skip) + .Take(take) + .Select(j => new MJournalInfo + { + Id = j.Id, + EventId = j.EventId, + CreatedAt = j.Timestamp + }) + .ToListAsync(); + + return new MRange_MJournalInfo + { + Skip = skip, + Count = totalCount, + Items = journalEntries + }; + } + } +} \ No newline at end of file diff --git a/Repositories/TreeRepository.cs b/Repositories/TreeRepository.cs new file mode 100644 index 0000000..b252ead --- /dev/null +++ b/Repositories/TreeRepository.cs @@ -0,0 +1,168 @@ +using Microsoft.EntityFrameworkCore; +using public_valetax.Data; +using public_valetax.Models; +using public_valetax.DTOs; +using public_valetax.Exceptions; + +namespace public_valetax.Repositories +{ + public class TreeRepository(ApplicationDbContext _context) : ITreeRepository + { + public async Task GetTreeByNameAsync(string name) + { + return await _context.Trees + .Include(t => t.Nodes) + .FirstOrDefaultAsync(t => t.Name == name); + } + + public async Task CreateTreeAsync(string name) + { + var tree = new Tree + { + Name = name, + CreatedAt = DateTime.UtcNow + }; + + if (await _context.Trees.AnyAsync(t => t.Name == name)) + { + throw new SecureException("Tree with such name already exist"); + } + + _context.Trees.Add(tree); + await _context.SaveChangesAsync(); + return tree; + } + + public async Task GetNodeByIdAsync(long nodeId) + { + return await _context.Nodes.FindAsync(nodeId); + } + + public async Task CreateNodeAsync(long treeId, long? parentId, string name) + { + // Verify that parent node belongs to the same tree if provided + if (parentId.HasValue) + { + var parentNode = await _context.Nodes.FindAsync(parentId.Value); + if (parentNode == null || parentNode.TreeId != treeId) + { + throw new SecureException("Parent node does not belong to the specified tree"); + } + } + + // Check if a node with the same name already exists in the same tree + var existingNode = await _context.Nodes + .FirstOrDefaultAsync(n => n.TreeId == treeId && n.Name == name && n.ParentId == parentId); + + if (existingNode != null) + { + throw new SecureException("A node with the same name already exists in this location"); + } + + var node = new Node + { + TreeId = treeId, + ParentId = parentId, + Name = name, + CreatedAt = DateTime.UtcNow + }; + + _context.Nodes.Add(node); + await _context.SaveChangesAsync(); + return node; + } + + public async Task DeleteNodeAsync(long nodeId) + { + var node = await _context.Nodes + .Include(n => n.Children) + .FirstOrDefaultAsync(n => n.Id == nodeId); + + if (node == null) + { + throw new SecureException("Node not found"); + } + + // Check if node has children + if (node.Children.Any()) + { + throw new SecureException("You have to delete all children nodes first"); + } + + _context.Nodes.Remove(node); + await _context.SaveChangesAsync(); + } + + public async Task RenameNodeAsync(long nodeId, string newName) + { + var node = await _context.Nodes.FindAsync(nodeId); + + if (node == null) + { + throw new SecureException("Node not found"); + } + + // Check if another node with the same name already exists in the same parent + var existingNode = await _context.Nodes + .FirstOrDefaultAsync(n => n.TreeId == node.TreeId && n.Name == newName && n.ParentId == node.ParentId && n.Id != nodeId); + + if (existingNode != null) + { + throw new SecureException("A node with the same name already exists in this location"); + } + + node.Name = newName; + await _context.SaveChangesAsync(); + } + + public async Task GetTreeStructureAsync(string treeName) + { + var tree = await _context.Trees + .Include(t => t.Nodes) + .FirstOrDefaultAsync(t => t.Name == treeName); + + if (tree == null) + { + return null; + } + + // Build the tree structure recursively + var rootNodes = tree.Nodes + .Where(n => n.ParentId == null) + .Select(n => BuildMNode(n, tree.Nodes)) + .ToList(); + + // If no root node exists, create one + if (!rootNodes.Any()) + { + return new MNode + { + Id = 0, + Name = tree.Name, + Children = [] + }; + } + + return rootNodes.First(); + } + + private MNode BuildMNode(Node node, ICollection allNodes) + { + var mNode = new MNode + { + Id = node.Id, + Name = node.Name, + Children = [] + }; + + // Add children recursively + var children = allNodes.Where(n => n.ParentId == node.Id).ToList(); + foreach (var child in children) + { + mNode.Children.Add(BuildMNode(child, allNodes)); + } + + return mNode; + } + } +} \ No newline at end of file diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..2cb69fb --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5433;Database=valetax;Username=postgres;Password=abc505050123XXX" + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/docs/ValetaxTest.pdf b/docs/ValetaxTest.pdf new file mode 100644 index 0000000..34fdaaa Binary files /dev/null and b/docs/ValetaxTest.pdf differ diff --git a/docs/valetax-test-swagger.yml b/docs/valetax-test-swagger.yml new file mode 100644 index 0000000..ca08e73 --- /dev/null +++ b/docs/valetax-test-swagger.yml @@ -0,0 +1,257 @@ +swagger: '2.0' +info: + title: Swagger + version: 0.0.1 +tags: + - name: user.journal + description: Represents journal API + - name: user.partner + description: Represents auth API + - name: user.tree + description: Represents entire tree API + - name: user.tree.node + description: Represents tree node API +paths: + /api.user.journal.getRange: + post: + summary: '' + description: >- + Provides the pagination API. Skip means the number of items should be + skipped by server. Take means the maximum number items should be + returned by server. All fields of the filter are optional. + tags: + - user.journal + parameters: + - in: query + name: skip + required: true + type: integer + format: int32 + - in: query + name: take + required: true + type: integer + format: int32 + - in: body + name: filter + required: false + schema: + type: object + responses: + '200': + schema: + $ref: '#/definitions/FxNet.Test.Model.MRange_MJournalInfo' + description: Successful response + /api.user.journal.getSingle: + post: + summary: '' + description: Returns the information about an particular event by ID. + tags: + - user.journal + parameters: + - in: query + name: id + required: true + type: integer + format: int64 + responses: + '200': + schema: + $ref: '#/definitions/FxNet.Test.Model.MJournal' + description: Successful response + /api.user.partner.rememberMe: + post: + summary: '' + description: >- + (Optional) Saves user by unique code and returns auth token required on all other requests, if implemented. + tags: + - user.partner + parameters: + - in: query + name: code + required: true + type: string + format: string + responses: + '200': + schema: + $ref: '#/definitions/FxNet.Test.Model.TokenInfo' + description: Successful response + /api.user.tree.get: + post: + summary: '' + description: >- + Returns your entire tree. If your tree doesn't exist it will be created + automatically. + tags: + - user.tree + parameters: + - in: query + name: treeName + required: true + type: string + format: string + responses: + '200': + schema: + $ref: '#/definitions/FxNet.Test.Model.MNode' + description: Successful response + /api.user.tree.node.create: + post: + summary: '' + description: >- + Create a new node in your tree. You must to specify a parent node ID + that belongs to your tree or dont pass parent ID to create tree first level node. A new node name must be unique across all + siblings. + tags: + - user.tree.node + parameters: + - in: query + name: treeName + required: true + type: string + format: string + - in: query + name: parentNodeId + required: false + type: integer + format: int64 + - in: query + name: nodeName + required: true + type: string + format: string + responses: + '200': + description: Successful response + /api.user.tree.node.delete: + post: + summary: '' + description: >- + Delete an existing node and all its descendants + tags: + - user.tree.node + parameters: + - in: query + name: nodeId + required: true + type: integer + format: int64 + responses: + '200': + description: Successful response + /api.user.tree.node.rename: + post: + summary: '' + description: >- + Rename an existing node in your tree. A new name of the node must be unique across all + siblings. + tags: + - user.tree.node + parameters: + - in: query + name: nodeId + required: true + type: integer + format: int64 + - in: query + name: newNodeName + required: true + type: string + format: string + responses: + '200': + description: Successful response +definitions: + FxNet.Test.Model.MJournal: + properties: + text: + type: string + format: string + id: + type: integer + format: int64 + eventId: + type: integer + format: int64 + createdAt: + type: string + format: datetime + example: '2025-05-23T12:18:16.9222634Z' + required: + - text + - id + - eventId + - createdAt + FxNet.Test.Model.MJournalInfo: + properties: + id: + type: integer + format: int64 + eventId: + type: integer + format: int64 + createdAt: + type: string + format: datetime + example: '2025-05-23T12:18:16.922346Z' + required: + - id + - eventId + - createdAt + FxNet.Test.View.VJournalFilter: + properties: + from: + type: string + format: datetime + example: '2025-05-23T12:18:16.9223615Z' + to: + type: string + format: datetime + example: '2025-05-23T12:18:16.9223726Z' + search: + type: string + format: string + required: + - search + FxNet.Test.Model.MNode: + properties: + id: + type: integer + format: int64 + name: + type: string + format: string + children: + type: array + items: + $ref: '#/definitions/FxNet.Test.Model.MNode' + required: + - id + - name + - children + FxNet.Test.Model.MRange_MJournalInfo: + properties: + skip: + type: integer + format: int32 + count: + type: integer + format: int32 + items: + type: array + items: + $ref: '#/definitions/FxNet.Test.Model.MJournalInfo' + required: + - skip + - count + - items + FxNet.Test.Model.TokenInfo: + properties: + token: + type: string + format: string + required: + - skip + - count + - items diff --git a/public-valetax.csproj b/public-valetax.csproj new file mode 100644 index 0000000..abd544d --- /dev/null +++ b/public-valetax.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + public_valetax + + + + + + + + + + + diff --git a/public-valetax.http b/public-valetax.http new file mode 100644 index 0000000..708264d --- /dev/null +++ b/public-valetax.http @@ -0,0 +1,32 @@ +@hostname = localhost +@port = 5090 +@host = {{hostname}}:{{port}} + +### Get Tree +POST http://{{host}}/api.tree/get?treeName=MyTree + +### Create Root Node +POST http://{{host}}/api.node/create?treeName=MyTree&nodeName=RootNode + +### Create Child Node +POST http://{{host}}/api.node/create?treeName=MyTree&parentNodeId=1&nodeName=ChildNode + +### Rename Node +POST http://{{host}}/api.node/rename?nodeId=1&newNodeName=NewRootName + +### Delete Node +POST http://{{host}}/api.node/delete?nodeId=2 + +### Get Journal Entries Range +POST http://{{host}}/api.journal/getRange?skip=0&take=10 +Content-Type: application/json + +{ + "search": "" +} + +### Get Single Journal Entry +POST http://{{host}}/api.journal/getSingle?id=1 + +### Authenticate (Optional) +POST http://{{host}}/api.partner/rememberMe?code=unique_code diff --git a/public-valetax.sln b/public-valetax.sln new file mode 100644 index 0000000..f2b63e0 --- /dev/null +++ b/public-valetax.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "public-valetax", "public-valetax.csproj", "{20B5CC40-8AC1-19AE-8AE8-DE856E2342D0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {20B5CC40-8AC1-19AE-8AE8-DE856E2342D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {20B5CC40-8AC1-19AE-8AE8-DE856E2342D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {20B5CC40-8AC1-19AE-8AE8-DE856E2342D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {20B5CC40-8AC1-19AE-8AE8-DE856E2342D0}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {DB931A39-1D59-483D-A80F-B7AAD3F311B4} + EndGlobalSection +EndGlobal diff --git a/screenshots/snapshot 2025-12-26 16-23-25.png b/screenshots/snapshot 2025-12-26 16-23-25.png new file mode 100644 index 0000000..3bd6880 Binary files /dev/null and b/screenshots/snapshot 2025-12-26 16-23-25.png differ diff --git a/screenshots/snapshot 2025-12-26 16-26-10.png b/screenshots/snapshot 2025-12-26 16-26-10.png new file mode 100644 index 0000000..331c86c Binary files /dev/null and b/screenshots/snapshot 2025-12-26 16-26-10.png differ