diff --git a/src/Application/Interfaces/Repositories/IAccountRepository.cs b/src/Application/Interfaces/Repositories/IAccountRepository.cs index 7e1c1d9..c5f628a 100644 --- a/src/Application/Interfaces/Repositories/IAccountRepository.cs +++ b/src/Application/Interfaces/Repositories/IAccountRepository.cs @@ -8,4 +8,6 @@ public interface IAccountRepository Task GetByIdAsync(long accountId); Task> GetAllAccounts(); Task> GetAllIdsAsync(); + Task> GetByFileIdAsync(long fileId); + Task DeleteByFileIdAsync(long fileId); } \ No newline at end of file diff --git a/src/Application/Interfaces/Repositories/IFileIdRepository.cs b/src/Application/Interfaces/Repositories/IFileIdRepository.cs new file mode 100644 index 0000000..3a22792 --- /dev/null +++ b/src/Application/Interfaces/Repositories/IFileIdRepository.cs @@ -0,0 +1,11 @@ +using Domain.Entities; + +namespace Application.Interfaces.Repositories; + +public interface IFileIdRepository +{ + Task IdExistsAsync(long fileId); + Task AddAsync(FileId fileId); + Task DeleteByIdAsync(long fileId); + Task> GetAllIdsAsync(); +} \ No newline at end of file diff --git a/src/Application/Interfaces/Repositories/ITransactionRepository.cs b/src/Application/Interfaces/Repositories/ITransactionRepository.cs index 684955e..f35f9f4 100644 --- a/src/Application/Interfaces/Repositories/ITransactionRepository.cs +++ b/src/Application/Interfaces/Repositories/ITransactionRepository.cs @@ -9,4 +9,6 @@ public interface ITransactionRepository Task> GetBySourceAccountId(long accountId); Task> GetByDestinationAccountId(long accountId); Task> GetAllIdsAsync(); + Task> GetByFileIdAsync(long fileId); + Task DeleteByFileIdAsync(long fileId); } \ No newline at end of file diff --git a/src/Application/Interfaces/Services/IAccountService.cs b/src/Application/Interfaces/Services/IAccountService.cs index 2fc8d60..65198c2 100644 --- a/src/Application/Interfaces/Services/IAccountService.cs +++ b/src/Application/Interfaces/Services/IAccountService.cs @@ -5,7 +5,9 @@ namespace Application.Interfaces.Services; public interface IAccountService { - Task AddAccountsFromCsvAsync(string filePath); + Task AddAccountsFromCsvAsync(string filePath, long fileId); Task> GetAccountByIdAsync(long accountId); Task>> GetAllAccountsAsync(); + Task>> GetAccountsByFileIdAsync(long fileId); + Task DeleteAccountsByFileIdAsync(long fileId); } \ No newline at end of file diff --git a/src/Application/Interfaces/Services/ITransactionService.cs b/src/Application/Interfaces/Services/ITransactionService.cs index fa26ab5..d705933 100644 --- a/src/Application/Interfaces/Services/ITransactionService.cs +++ b/src/Application/Interfaces/Services/ITransactionService.cs @@ -6,7 +6,9 @@ namespace Application.Interfaces.Services; public interface ITransactionService { - Task AddTransactionsFromCsvAsync(string filePath); + Task AddTransactionsFromCsvAsync(string filePath, long fileId); Task>> GetAllTransactionsAsync(); Task>> GetTransactionsByAccountIdAsync(long accountId); + Task>> GetTransactionsByFileIdAsync(long fileId); + Task DeleteTransactionsByFileIdAsync(long fileId); } \ No newline at end of file diff --git a/src/Application/Mappers/AccountMapper.cs b/src/Application/Mappers/AccountMapper.cs index ceef8a3..f5dcdb1 100644 --- a/src/Application/Mappers/AccountMapper.cs +++ b/src/Application/Mappers/AccountMapper.cs @@ -5,7 +5,7 @@ namespace Application.Mappers; public static class AccountMapper { - public static Account ToAccount(this AccountCsvModel csvModel) + public static Account ToAccount(this AccountCsvModel csvModel, long fileId) { return new Account { @@ -18,7 +18,8 @@ public static Account ToAccount(this AccountCsvModel csvModel) BranchName = csvModel.BranchName, OwnerName = csvModel.OwnerName, OwnerLastName = csvModel.OwnerLastName, - OwnerId = csvModel.OwnerId + OwnerId = csvModel.OwnerId, + FileId = fileId }; } } \ No newline at end of file diff --git a/src/Application/Mappers/TransactionMapper.cs b/src/Application/Mappers/TransactionMapper.cs index 79bf50e..0946448 100644 --- a/src/Application/Mappers/TransactionMapper.cs +++ b/src/Application/Mappers/TransactionMapper.cs @@ -4,7 +4,7 @@ namespace Application.Mappers; public static class TransactionMapper { - public static Transaction ToTransaction(this TransactionCsvModel csvModel) + public static Transaction ToTransaction(this TransactionCsvModel csvModel, long fileId) { var date = DateOnly.Parse(csvModel.Date).ToDateTime(TimeOnly.Parse(csvModel.Time)); var utcDate = DateTime.SpecifyKind(date, DateTimeKind.Utc); @@ -16,7 +16,8 @@ public static Transaction ToTransaction(this TransactionCsvModel csvModel) Amount = csvModel.Amount, Date = utcDate, Type = csvModel.Type, - TrackingId = csvModel.TrackingId + TrackingId = csvModel.TrackingId, + FileId = fileId }; } } \ No newline at end of file diff --git a/src/Application/Services/DomainService/AccountService.cs b/src/Application/Services/DomainService/AccountService.cs index b4a16a4..fd8bbfc 100644 --- a/src/Application/Services/DomainService/AccountService.cs +++ b/src/Application/Services/DomainService/AccountService.cs @@ -13,26 +13,34 @@ public class AccountService : IAccountService { private readonly IAccountRepository _accountRepository; private readonly IFileReaderService _fileReaderService; + private readonly IFileIdRepository _fileIdRepository; - public AccountService(IAccountRepository accountRepository, IFileReaderService fileReaderService) + public AccountService(IAccountRepository accountRepository, IFileReaderService fileReaderService, IFileIdRepository fileIdRepository) { _accountRepository = accountRepository; _fileReaderService = fileReaderService; + _fileIdRepository = fileIdRepository; } - public async Task AddAccountsFromCsvAsync(string filePath) + public async Task AddAccountsFromCsvAsync(string filePath, long fileId) { try { var accountCsvModels = _fileReaderService.ReadFromFile(filePath); var accounts = accountCsvModels - .Select(csvModel => csvModel.ToAccount()) + .Select(csvModel => csvModel.ToAccount(fileId)) .ToList(); var existingAccountIds = await _accountRepository.GetAllIdsAsync(); var newAccounts = accounts.Where(a => !existingAccountIds.Contains(a.AccountId)).ToList(); - + + var fileAlreadyExists = await _fileIdRepository.IdExistsAsync(fileId); + if (fileAlreadyExists) + { + return Result.Fail("File-Id already exists"); + } + await _fileIdRepository.AddAsync(new FileId { Id = fileId }); await _accountRepository.CreateBulkAsync(newAccounts); return Result.Ok(); } @@ -72,4 +80,39 @@ public async Task>> GetAllAccountsAsync() return Result>.Fail($"An unexpected error occurred: {ex.Message}"); } } + + public async Task>> GetAccountsByFileIdAsync(long fileId) + { + try + { + if (!await _fileIdRepository.IdExistsAsync(fileId)) + { + return Result>.Fail("File-Id not found"); + } + var accounts = await _accountRepository.GetByFileIdAsync(fileId); + return Result>.Ok(accounts); + } + catch (Exception ex) + { + return Result>.Fail($"An unexpected error occurred: {ex.Message}"); + } + } + + public async Task DeleteAccountsByFileIdAsync(long fileId) + { + try + { + if (!await _fileIdRepository.IdExistsAsync(fileId)) + { + return Result>.Fail("File-Id not found"); + } + await _accountRepository.DeleteByFileIdAsync(fileId); + await _fileIdRepository.DeleteByIdAsync(fileId); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail($"An unexpected error occurred: {ex.Message}"); + } + } } \ No newline at end of file diff --git a/src/Application/Services/DomainService/TransactionService.cs b/src/Application/Services/DomainService/TransactionService.cs index 17f31cd..25f11db 100644 --- a/src/Application/Services/DomainService/TransactionService.cs +++ b/src/Application/Services/DomainService/TransactionService.cs @@ -14,24 +14,30 @@ public class TransactionService : ITransactionService private readonly ITransactionRepository _transactionRepository; private readonly IFileReaderService _fileReaderService; private readonly IAccountRepository _accountRepository; - - public TransactionService(ITransactionRepository transactionRepository, IFileReaderService fileReaderService, IAccountRepository accountRepository) + private readonly IFileIdRepository _fileIdRepository; + public TransactionService(ITransactionRepository transactionRepository, + IFileReaderService fileReaderService, + IAccountRepository accountRepository, + IFileIdRepository fileIdRepository) { _transactionRepository = transactionRepository; _fileReaderService = fileReaderService; _accountRepository = accountRepository; + _fileIdRepository = fileIdRepository; } - private async Task> ValidateTransactionCsvModelsAsync(List transactionCsvModels) + private async Task> ValidateTransactionCsvModelsAsync(List transactionCsvModels, long fileId) { var invalidTransactionIds = new List(); foreach (var transactionCsvModel in transactionCsvModels) { var sourceAccount = await _accountRepository.GetByIdAsync(transactionCsvModel.SourceAccount); var destinationAccount = await _accountRepository.GetByIdAsync(transactionCsvModel.DestinationAccount); + bool isValidSourceAccount = (sourceAccount != null) && (sourceAccount.FileId == fileId); + bool isValidDestinationAccount = (destinationAccount != null) && (destinationAccount.FileId == fileId); bool isValidDate = DateOnly.TryParseExact(transactionCsvModel.Date, "MM/dd/yyyy", null, System.Globalization.DateTimeStyles.None, out var date); bool isValidTime = TimeOnly.TryParse(transactionCsvModel.Time, out var time); - if(sourceAccount == null || destinationAccount == null || !isValidDate || !isValidTime) + if(!isValidSourceAccount || !isValidDestinationAccount || !isValidDate || !isValidTime) { invalidTransactionIds.Add(transactionCsvModel.TransactionId); } @@ -39,24 +45,29 @@ private async Task> ValidateTransactionCsvModelsAsync(List AddTransactionsFromCsvAsync(string filePath) + public async Task AddTransactionsFromCsvAsync(string filePath, long fileId) { try { var transactionCsvModels = _fileReaderService.ReadFromFile(filePath); - var invalidTransactionCsvModels = await ValidateTransactionCsvModelsAsync(transactionCsvModels); + var invalidTransactionCsvModels = await ValidateTransactionCsvModelsAsync(transactionCsvModels, fileId); var transactions = transactionCsvModels .Where(csvModel => !invalidTransactionCsvModels.Contains(csvModel.TransactionId)) - .Select(csvModel => csvModel.ToTransaction()) + .Select(csvModel => csvModel.ToTransaction(fileId)) .ToList(); var existingTransactionsIds = await _transactionRepository.GetAllIdsAsync(); var newTransactions = transactions.Where(t => !existingTransactionsIds.Contains(t.TransactionId)).ToList(); + var fileAlreadyExists = await _fileIdRepository.IdExistsAsync(fileId); + if (!fileAlreadyExists) + { + return Result.Fail("File-Id do not exist"); + } await _transactionRepository.CreateBulkAsync(newTransactions); return Result.Ok(invalidTransactionCsvModels.Count == 0 ? "All transactions were added successfully." - : $"Some transactions were not added because of invalid data: {string.Join(", ", invalidTransactionCsvModels)}"); + : $"{invalidTransactionCsvModels.Count} transactions were not added because of invalid data: {string.Join(", ", invalidTransactionCsvModels)}"); } catch (Exception ex) { @@ -121,4 +132,38 @@ public async Task>> GetTransacti return Result>.Fail($"An error occurred: {ex.Message}"); } } + + public async Task>> GetTransactionsByFileIdAsync(long fileId) + { + try + { + if (!await _fileIdRepository.IdExistsAsync(fileId)) + { + return Result>.Fail("File-Id not found"); + } + var transactions = await _transactionRepository.GetByFileIdAsync(fileId); + return Result>.Ok(transactions); + } + catch (Exception ex) + { + return Result>.Fail($"An error occurred: {ex.Message}"); + } + } + + public async Task DeleteTransactionsByFileIdAsync(long fileId) + { + try + { + if (!await _fileIdRepository.IdExistsAsync(fileId)) + { + return Result.Fail("File-Id not found"); + } + await _transactionRepository.DeleteByFileIdAsync(fileId); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail($"An error occurred: {ex.Message}"); + } + } } \ No newline at end of file diff --git a/src/Domain/Entities/Account.cs b/src/Domain/Entities/Account.cs index c32ac84..3d0bde1 100644 --- a/src/Domain/Entities/Account.cs +++ b/src/Domain/Entities/Account.cs @@ -30,6 +30,8 @@ public class Account [MaxLength(50)] public string OwnerLastName { get; set; } = String.Empty; public long OwnerId { get; set; } + public long FileId { get; set; } + public FileId? File { get; set; } public List SourceTransactions { get; set; } = new(); public List DestinationTransactions { get; set; } = new(); diff --git a/src/Domain/Entities/FileId.cs b/src/Domain/Entities/FileId.cs new file mode 100644 index 0000000..b05ab50 --- /dev/null +++ b/src/Domain/Entities/FileId.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Domain.Entities; + +[Table("FileIds")] +public class FileId +{ + [Key] + public long Id { get; set; } +} \ No newline at end of file diff --git a/src/Domain/Entities/Transaction.cs b/src/Domain/Entities/Transaction.cs index 5b34ccb..5200b39 100644 --- a/src/Domain/Entities/Transaction.cs +++ b/src/Domain/Entities/Transaction.cs @@ -16,4 +16,6 @@ public class Transaction [MaxLength(50)] public string Type { get; set; } = String.Empty; public long TrackingId { get; set; } + public long FileId { get; set; } + public FileId? File { get; set; } } \ No newline at end of file diff --git a/src/Infrastructure/Data/ApplicationDbContext.cs b/src/Infrastructure/Data/ApplicationDbContext.cs index 977cdf6..074f9b2 100644 --- a/src/Infrastructure/Data/ApplicationDbContext.cs +++ b/src/Infrastructure/Data/ApplicationDbContext.cs @@ -11,6 +11,7 @@ public class ApplicationDbContext(DbContextOptions dbConte { public DbSet Accounts { get; set; } public DbSet Transactions { get; set; } + public DbSet Files { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -34,5 +35,17 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithMany(a => a.DestinationTransactions) .HasForeignKey(t => t.DestinationAccountId) .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(t => t.File) + .WithMany() + .HasForeignKey(t => t.FileId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(t => t.File) + .WithMany() + .HasForeignKey(t => t.FileId) + .OnDelete(DeleteBehavior.Restrict); } } \ No newline at end of file diff --git a/src/Infrastructure/Migrations/20240907102333_update-transaction-entity.Designer.cs b/src/Infrastructure/Migrations/20240908122915_add-data-categorization.Designer.cs similarity index 89% rename from src/Infrastructure/Migrations/20240907102333_update-transaction-entity.Designer.cs rename to src/Infrastructure/Migrations/20240908122915_add-data-categorization.Designer.cs index 429c292..5a90bd8 100644 --- a/src/Infrastructure/Migrations/20240907102333_update-transaction-entity.Designer.cs +++ b/src/Infrastructure/Migrations/20240908122915_add-data-categorization.Designer.cs @@ -12,8 +12,8 @@ namespace Infrastructure.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20240907102333_update-transaction-entity")] - partial class updatetransactionentity + [Migration("20240908122915_add-data-categorization")] + partial class adddatacategorization { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -56,6 +56,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("CardId") .HasColumnType("bigint"); + b.Property("FileId") + .HasColumnType("bigint"); + b.Property("Iban") .IsRequired() .HasMaxLength(50) @@ -76,6 +79,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("AccountId"); + b.HasIndex("FileId"); + b.ToTable("Accounts"); }); @@ -153,6 +158,19 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("Domain.Entities.FileId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.HasKey("Id"); + + b.ToTable("FileIds"); + }); + modelBuilder.Entity("Domain.Entities.Transaction", b => { b.Property("TransactionId") @@ -170,6 +188,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("DestinationAccountId") .HasColumnType("bigint"); + b.Property("FileId") + .HasColumnType("bigint"); + b.Property("SourceAccountId") .HasColumnType("bigint"); @@ -185,6 +206,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("DestinationAccountId"); + b.HasIndex("FileId"); + b.HasIndex("SourceAccountId"); b.ToTable("Transactions"); @@ -218,19 +241,19 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasData( new { - Id = "8a0429eb-6031-4546-a06e-0a0f5ae9248c", + Id = "e1624a94-c648-4bcd-9e55-d885a2057a9b", Name = "Admin", NormalizedName = "ADMIN" }, new { - Id = "b70df7e0-cb8a-4131-bf26-3d2e52ee9834", + Id = "5031ea4e-f3ab-44f7-b7cf-ff2c6253a0f0", Name = "DataAdmin", NormalizedName = "DATAADMIN" }, new { - Id = "31f9f360-a3de-4dc3-bd7d-8f16d8da7fed", + Id = "546d4bc0-6f2d-4d74-af64-c83b1b99f6c6", Name = "DataAnalyst", NormalizedName = "DATAANALYST" }); @@ -342,6 +365,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserTokens", (string)null); }); + modelBuilder.Entity("Domain.Entities.Account", b => + { + b.HasOne("Domain.Entities.FileId", "File") + .WithMany() + .HasForeignKey("FileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("File"); + }); + modelBuilder.Entity("Domain.Entities.Transaction", b => { b.HasOne("Domain.Entities.Account", "DestinationAccount") @@ -350,6 +384,12 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.HasOne("Domain.Entities.FileId", "File") + .WithMany() + .HasForeignKey("FileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + b.HasOne("Domain.Entities.Account", "SourceAccount") .WithMany("SourceTransactions") .HasForeignKey("SourceAccountId") @@ -358,6 +398,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("DestinationAccount"); + b.Navigation("File"); + b.Navigation("SourceAccount"); }); diff --git a/src/Infrastructure/Migrations/20240907102333_update-transaction-entity.cs b/src/Infrastructure/Migrations/20240908122915_add-data-categorization.cs similarity index 87% rename from src/Infrastructure/Migrations/20240907102333_update-transaction-entity.cs rename to src/Infrastructure/Migrations/20240908122915_add-data-categorization.cs index 939c37f..7a16806 100644 --- a/src/Infrastructure/Migrations/20240907102333_update-transaction-entity.cs +++ b/src/Infrastructure/Migrations/20240908122915_add-data-categorization.cs @@ -9,32 +9,11 @@ namespace Infrastructure.Migrations { /// - public partial class updatetransactionentity : Migration + public partial class adddatacategorization : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.CreateTable( - name: "Accounts", - columns: table => new - { - AccountId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - CardId = table.Column(type: "bigint", nullable: false), - Iban = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - AccountType = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - BranchTelephone = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - BranchAddress = table.Column(type: "character varying(150)", maxLength: 150, nullable: false), - BranchName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - OwnerName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - OwnerLastName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - OwnerId = table.Column(type: "bigint", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Accounts", x => x.AccountId); - }); - migrationBuilder.CreateTable( name: "AspNetRoles", columns: table => new @@ -77,33 +56,15 @@ protected override void Up(MigrationBuilder migrationBuilder) }); migrationBuilder.CreateTable( - name: "Transactions", + name: "FileIds", columns: table => new { - TransactionId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - SourceAccountId = table.Column(type: "bigint", nullable: false), - DestinationAccountId = table.Column(type: "bigint", nullable: false), - Amount = table.Column(type: "numeric", nullable: false), - Date = table.Column(type: "timestamp with time zone", nullable: false), - Type = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - TrackingId = table.Column(type: "bigint", nullable: false) + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn) }, constraints: table => { - table.PrimaryKey("PK_Transactions", x => x.TransactionId); - table.ForeignKey( - name: "FK_Transactions_Accounts_DestinationAccountId", - column: x => x.DestinationAccountId, - principalTable: "Accounts", - principalColumn: "AccountId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_Transactions_Accounts_SourceAccountId", - column: x => x.SourceAccountId, - principalTable: "Accounts", - principalColumn: "AccountId", - onDelete: ReferentialAction.Restrict); + table.PrimaryKey("PK_FileIds", x => x.Id); }); migrationBuilder.CreateTable( @@ -212,16 +173,86 @@ protected override void Up(MigrationBuilder migrationBuilder) onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "Accounts", + columns: table => new + { + AccountId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CardId = table.Column(type: "bigint", nullable: false), + Iban = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + AccountType = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + BranchTelephone = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + BranchAddress = table.Column(type: "character varying(150)", maxLength: 150, nullable: false), + BranchName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + OwnerName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + OwnerLastName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + OwnerId = table.Column(type: "bigint", nullable: false), + FileId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Accounts", x => x.AccountId); + table.ForeignKey( + name: "FK_Accounts_FileIds_FileId", + column: x => x.FileId, + principalTable: "FileIds", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Transactions", + columns: table => new + { + TransactionId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + SourceAccountId = table.Column(type: "bigint", nullable: false), + DestinationAccountId = table.Column(type: "bigint", nullable: false), + Amount = table.Column(type: "numeric", nullable: false), + Date = table.Column(type: "timestamp with time zone", nullable: false), + Type = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + TrackingId = table.Column(type: "bigint", nullable: false), + FileId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Transactions", x => x.TransactionId); + table.ForeignKey( + name: "FK_Transactions_Accounts_DestinationAccountId", + column: x => x.DestinationAccountId, + principalTable: "Accounts", + principalColumn: "AccountId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Transactions_Accounts_SourceAccountId", + column: x => x.SourceAccountId, + principalTable: "Accounts", + principalColumn: "AccountId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Transactions_FileIds_FileId", + column: x => x.FileId, + principalTable: "FileIds", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + migrationBuilder.InsertData( table: "AspNetRoles", columns: new[] { "Id", "ConcurrencyStamp", "Name", "NormalizedName" }, values: new object[,] { - { "31f9f360-a3de-4dc3-bd7d-8f16d8da7fed", null, "DataAnalyst", "DATAANALYST" }, - { "8a0429eb-6031-4546-a06e-0a0f5ae9248c", null, "Admin", "ADMIN" }, - { "b70df7e0-cb8a-4131-bf26-3d2e52ee9834", null, "DataAdmin", "DATAADMIN" } + { "5031ea4e-f3ab-44f7-b7cf-ff2c6253a0f0", null, "DataAdmin", "DATAADMIN" }, + { "546d4bc0-6f2d-4d74-af64-c83b1b99f6c6", null, "DataAnalyst", "DATAANALYST" }, + { "e1624a94-c648-4bcd-9e55-d885a2057a9b", null, "Admin", "ADMIN" } }); + migrationBuilder.CreateIndex( + name: "IX_Accounts_FileId", + table: "Accounts", + column: "FileId"); + migrationBuilder.CreateIndex( name: "IX_AspNetRoleClaims_RoleId", table: "AspNetRoleClaims", @@ -264,6 +295,11 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "Transactions", column: "DestinationAccountId"); + migrationBuilder.CreateIndex( + name: "IX_Transactions_FileId", + table: "Transactions", + column: "FileId"); + migrationBuilder.CreateIndex( name: "IX_Transactions_SourceAccountId", table: "Transactions", @@ -299,6 +335,9 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropTable( name: "Accounts"); + + migrationBuilder.DropTable( + name: "FileIds"); } } } diff --git a/src/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index 6f3faaf..79b263c 100644 --- a/src/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -53,6 +53,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CardId") .HasColumnType("bigint"); + b.Property("FileId") + .HasColumnType("bigint"); + b.Property("Iban") .IsRequired() .HasMaxLength(50) @@ -73,6 +76,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("AccountId"); + b.HasIndex("FileId"); + b.ToTable("Accounts"); }); @@ -150,6 +155,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("Domain.Entities.FileId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.HasKey("Id"); + + b.ToTable("FileIds"); + }); + modelBuilder.Entity("Domain.Entities.Transaction", b => { b.Property("TransactionId") @@ -167,6 +185,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DestinationAccountId") .HasColumnType("bigint"); + b.Property("FileId") + .HasColumnType("bigint"); + b.Property("SourceAccountId") .HasColumnType("bigint"); @@ -182,6 +203,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("DestinationAccountId"); + b.HasIndex("FileId"); + b.HasIndex("SourceAccountId"); b.ToTable("Transactions"); @@ -215,19 +238,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasData( new { - Id = "8a0429eb-6031-4546-a06e-0a0f5ae9248c", + Id = "e1624a94-c648-4bcd-9e55-d885a2057a9b", Name = "Admin", NormalizedName = "ADMIN" }, new { - Id = "b70df7e0-cb8a-4131-bf26-3d2e52ee9834", + Id = "5031ea4e-f3ab-44f7-b7cf-ff2c6253a0f0", Name = "DataAdmin", NormalizedName = "DATAADMIN" }, new { - Id = "31f9f360-a3de-4dc3-bd7d-8f16d8da7fed", + Id = "546d4bc0-6f2d-4d74-af64-c83b1b99f6c6", Name = "DataAnalyst", NormalizedName = "DATAANALYST" }); @@ -339,6 +362,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserTokens", (string)null); }); + modelBuilder.Entity("Domain.Entities.Account", b => + { + b.HasOne("Domain.Entities.FileId", "File") + .WithMany() + .HasForeignKey("FileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("File"); + }); + modelBuilder.Entity("Domain.Entities.Transaction", b => { b.HasOne("Domain.Entities.Account", "DestinationAccount") @@ -347,6 +381,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.HasOne("Domain.Entities.FileId", "File") + .WithMany() + .HasForeignKey("FileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + b.HasOne("Domain.Entities.Account", "SourceAccount") .WithMany("SourceTransactions") .HasForeignKey("SourceAccountId") @@ -355,6 +395,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("DestinationAccount"); + b.Navigation("File"); + b.Navigation("SourceAccount"); }); diff --git a/src/Infrastructure/Repositories/AccountRepository.cs b/src/Infrastructure/Repositories/AccountRepository.cs index 705bcfb..605a300 100644 --- a/src/Infrastructure/Repositories/AccountRepository.cs +++ b/src/Infrastructure/Repositories/AccountRepository.cs @@ -36,4 +36,19 @@ public async Task> GetAllIdsAsync() .Select(a => a.AccountId) .ToListAsync(); } + + public async Task> GetByFileIdAsync(long fileId) + { + return await _dbContext.Accounts + .Where(a => a.FileId == fileId) + .ToListAsync(); + } + + public async Task DeleteByFileIdAsync(long fileId) + { + var accounts = _dbContext.Accounts + .Where(a => a.FileId == fileId); + _dbContext.Accounts.RemoveRange(accounts); + await _dbContext.SaveChangesAsync(); + } } \ No newline at end of file diff --git a/src/Infrastructure/Repositories/FileIdRepository.cs b/src/Infrastructure/Repositories/FileIdRepository.cs new file mode 100644 index 0000000..2ff7cbe --- /dev/null +++ b/src/Infrastructure/Repositories/FileIdRepository.cs @@ -0,0 +1,40 @@ +using Application.Interfaces.Repositories; +using Domain.Entities; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Repositories; + +public class FileIdRepository : IFileIdRepository +{ + private ApplicationDbContext _dbContext; + + public FileIdRepository(ApplicationDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task IdExistsAsync(long fileId) + { + var id = await _dbContext.Files.FindAsync(fileId); + return id != null; + } + + public async Task AddAsync(FileId fileId) + { + await _dbContext.Files.AddAsync(fileId); + await _dbContext.SaveChangesAsync(); + } + + public async Task DeleteByIdAsync(long fileId) + { + var id = await _dbContext.Files.FindAsync(fileId); + _dbContext.Files.Remove(id!); + await _dbContext.SaveChangesAsync(); + } + + public async Task> GetAllIdsAsync() + { + return await _dbContext.Files.ToListAsync(); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Repositories/TransactionRepository.cs b/src/Infrastructure/Repositories/TransactionRepository.cs index edc5a6c..aeeaeb4 100644 --- a/src/Infrastructure/Repositories/TransactionRepository.cs +++ b/src/Infrastructure/Repositories/TransactionRepository.cs @@ -44,4 +44,19 @@ public async Task> GetAllIdsAsync() .Select(a => a.TransactionId) .ToListAsync(); } + + public async Task> GetByFileIdAsync(long fileId) + { + return await _dbContext.Transactions + .Where(transaction => transaction.FileId == fileId) + .ToListAsync(); + } + + public Task DeleteByFileIdAsync(long fileId) + { + var transactions = _dbContext.Transactions + .Where(transaction => transaction.FileId == fileId); + _dbContext.Transactions.RemoveRange(transactions); + return _dbContext.SaveChangesAsync(); + } } \ No newline at end of file diff --git a/src/Web/Controllers/AccountsController.cs b/src/Web/Controllers/AccountsController.cs index 58d04e0..87714b2 100644 --- a/src/Web/Controllers/AccountsController.cs +++ b/src/Web/Controllers/AccountsController.cs @@ -25,7 +25,7 @@ public AccountsController(IAccountService accountService) [RequiresAnyRole(Claims.Role, AppRoles.Admin, AppRoles.DataAdmin)] [ProducesResponseType(200)] [ProducesResponseType(400)] - public async Task UploadAccounts([FromForm] IFormFile file) + public async Task UploadAccounts([FromForm] IFormFile file, [FromForm] long fileId) { if (file.Length == 0) return BadRequest("No file uploaded."); @@ -36,8 +36,8 @@ public async Task UploadAccounts([FromForm] IFormFile file) { await file.CopyToAsync(stream); } - - var result = await _accountService.AddAccountsFromCsvAsync(filePath); + + var result = await _accountService.AddAccountsFromCsvAsync(filePath, fileId); if (!result.Succeed) { var errorResponse = Errors.New(nameof(UploadAccounts), result.Message); @@ -83,4 +83,39 @@ public async Task GetAllAccounts() var response = allAccounts.Value!; return Ok(response.ToGotAllAccountsDto()); } + + [HttpGet("by-file-id/{fileId}")] + [Authorize] + [RequiresAnyRole(Claims.Role, AppRoles.Admin, AppRoles.DataAdmin, AppRoles.DataAnalyst)] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + public async Task GetAccountsByFileId(long fileId) + { + var accounts = await _accountService.GetAccountsByFileIdAsync(fileId); + if (!accounts.Succeed) + { + var errorResponse = Errors.New(nameof(GetAccountsByFileId), accounts.Message); + return BadRequest(errorResponse); + } + + var response = accounts.Value!; + return Ok(response.ToGotAllAccountsDto()); + } + + [HttpDelete("{fileId}")] + [Authorize] + [RequiresAnyRole(Claims.Role, AppRoles.Admin, AppRoles.DataAdmin)] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + public async Task DeleteAccountsByFileId(long fileId) + { + var result = await _accountService.DeleteAccountsByFileIdAsync(fileId); + if (!result.Succeed) + { + var errorResponse = Errors.New(nameof(DeleteAccountsByFileId), result.Message); + return BadRequest(errorResponse); + } + + return Ok("Accounts deleted successfully!"); + } } \ No newline at end of file diff --git a/src/Web/Controllers/FileIdController.cs b/src/Web/Controllers/FileIdController.cs new file mode 100644 index 0000000..70dfd81 --- /dev/null +++ b/src/Web/Controllers/FileIdController.cs @@ -0,0 +1,30 @@ +using Application.Interfaces.Repositories; +using Domain.Constants; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Web.AccessControl; + +namespace Web.Controllers; + +[ApiController] +[Route("file-ids")] +public class FileIdController : ControllerBase +{ + private readonly IFileIdRepository _fileIdRepository; + + public FileIdController(IFileIdRepository fileIdRepository) + { + _fileIdRepository = fileIdRepository; + } + + [HttpGet] + [Authorize] + [RequiresAnyRole(Claims.Role, AppRoles.Admin, AppRoles.DataAdmin)] + [ProducesResponseType(200)] + + public async Task GetAllFileIds() + { + var fileIds = await _fileIdRepository.GetAllIdsAsync(); + return Ok(fileIds); + } +} \ No newline at end of file diff --git a/src/Web/Controllers/TransactionsController.cs b/src/Web/Controllers/TransactionsController.cs index ee13799..be6a44c 100644 --- a/src/Web/Controllers/TransactionsController.cs +++ b/src/Web/Controllers/TransactionsController.cs @@ -26,7 +26,7 @@ public TransactionsController(ITransactionService transactionService) [ProducesResponseType(400)] [ProducesResponseType(401)] [ProducesResponseType(403)] - public async Task UploadTransactions([FromForm] IFormFile file) + public async Task UploadTransactions([FromForm] IFormFile file, [FromForm] long fileId) { if (file.Length == 0) return BadRequest("No file uploaded."); @@ -38,7 +38,7 @@ public async Task UploadTransactions([FromForm] IFormFile file) await file.CopyToAsync(stream); } - var result = await _transactionService.AddTransactionsFromCsvAsync(filePath); + var result = await _transactionService.AddTransactionsFromCsvAsync(filePath, fileId); if (!result.Succeed) { @@ -89,4 +89,44 @@ public async Task GetTransactionsByAccountId(long accountId) return Ok(response); } + + [HttpGet("by-file-id/{fileId}")] + [Authorize] + [RequiresAnyRole(Claims.Role, AppRoles.Admin, AppRoles.DataAdmin, AppRoles.DataAnalyst)] + [ProducesResponseType(200)] + [ProducesResponseType(401)] + [ProducesResponseType(403)] + public async Task GetTransactionsByFileId(long fileId) + { + var transactions = await _transactionService.GetTransactionsByFileIdAsync(fileId); + + if (!transactions.Succeed) + { + var errorResponse = Errors.New(nameof(GetAllTransactions), transactions.Message); + return BadRequest(errorResponse); + } + + var response = transactions.Value!.ToGotAllTransactionsDto(); + + return Ok(response); + } + + [HttpDelete("{fileId}")] + [Authorize] + [RequiresAnyRole(Claims.Role, AppRoles.Admin, AppRoles.DataAdmin)] + [ProducesResponseType(200)] + [ProducesResponseType(401)] + [ProducesResponseType(403)] + public async Task DeleteTransactionsByFileId(long fileId) + { + var result = await _transactionService.DeleteTransactionsByFileIdAsync(fileId); + + if (!result.Succeed) + { + var errorResponse = Errors.New(nameof(DeleteTransactionsByFileId), result.Message); + return BadRequest(errorResponse); + } + + return Ok(result.Message); + } } \ No newline at end of file diff --git a/src/Web/Startup/ServiceExtensions.DI.cs b/src/Web/Startup/ServiceExtensions.DI.cs index 13f954f..9d28125 100644 --- a/src/Web/Startup/ServiceExtensions.DI.cs +++ b/src/Web/Startup/ServiceExtensions.DI.cs @@ -19,6 +19,7 @@ public static void AddApplicationServices(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/test/Application.UnitTests/Services/DomainService/AccountServiceTests.cs b/test/Application.UnitTests/Services/DomainService/AccountServiceTests.cs index 965ae8e..13f0369 100644 --- a/test/Application.UnitTests/Services/DomainService/AccountServiceTests.cs +++ b/test/Application.UnitTests/Services/DomainService/AccountServiceTests.cs @@ -15,12 +15,14 @@ public class AccountServiceTests private readonly IAccountRepository _accountRepository; private readonly AccountService _accountService; private readonly IFileReaderService _fileReaderService; + private readonly IFileIdRepository _fileIdRepository; public AccountServiceTests() { + _fileIdRepository = Substitute.For(); _accountRepository = Substitute.For(); _fileReaderService = Substitute.For(); - _accountService = new AccountService(_accountRepository, _fileReaderService); + _accountService = new AccountService(_accountRepository, _fileReaderService, _fileIdRepository); } [Fact] @@ -61,9 +63,10 @@ public async Task AddAccountsFromCsvAsync_WhenCsvIsValid_ReturnsOk() _fileReaderService.ReadFromFile(filePath).Returns(accountCsvModels); _accountRepository.GetAllIdsAsync().Returns(existingAccountIds); + _fileIdRepository.IdExistsAsync(1).Returns(false); // Act - var result = await _accountService.AddAccountsFromCsvAsync(filePath); + var result = await _accountService.AddAccountsFromCsvAsync(filePath, 1); // Assert Assert.True(result.Succeed); @@ -83,9 +86,10 @@ public async Task AddAccountsFromCsvAsync_WhenExceptionIsThrown_ReturnsFail() _fileReaderService .When(x => x.ReadFromFile(filePath)) .Do(x => { throw new Exception("CSV read error"); }); + _fileIdRepository.IdExistsAsync(1).Returns(false); // Act - var result = await _accountService.AddAccountsFromCsvAsync(filePath); + var result = await _accountService.AddAccountsFromCsvAsync(filePath, 1); // Assert Assert.False(result.Succeed); diff --git a/test/Application.UnitTests/Services/DomainService/TransactionServiceTests.cs b/test/Application.UnitTests/Services/DomainService/TransactionServiceTests.cs index e14c065..17273df 100644 --- a/test/Application.UnitTests/Services/DomainService/TransactionServiceTests.cs +++ b/test/Application.UnitTests/Services/DomainService/TransactionServiceTests.cs @@ -14,12 +14,14 @@ public class TransactionServiceTests private readonly IFileReaderService _fileReaderService; private readonly TransactionService _transactionService; private readonly IAccountRepository _accountRepository; + private readonly IFileIdRepository _fileIdRepository; public TransactionServiceTests() { + _fileIdRepository = Substitute.For(); _transactionRepository = Substitute.For(); _fileReaderService = Substitute.For(); _accountRepository = Substitute.For(); - _transactionService = new TransactionService(_transactionRepository, _fileReaderService, _accountRepository); + _transactionService = new TransactionService(_transactionRepository, _fileReaderService, _accountRepository, _fileIdRepository); } [Fact] @@ -41,12 +43,13 @@ public async Task AddTransactionsFromCsvAsync_ShouldReturnOk_WhenTransactionsAre _fileReaderService.ReadFromFile(filePath).Returns(transactionCsvModels); _transactionRepository.GetAllIdsAsync().Returns(existingTransactionIds); _transactionRepository.CreateBulkAsync(Arg.Any>()).Returns(Task.CompletedTask); - _accountRepository.GetByIdAsync(101).Returns(new Account()); - _accountRepository.GetByIdAsync(102).Returns(new Account()); - _accountRepository.GetByIdAsync(103).Returns(new Account()); + _accountRepository.GetByIdAsync(101).Returns(new Account {FileId = 1}); + _accountRepository.GetByIdAsync(102).Returns(new Account {FileId = 1}); + _accountRepository.GetByIdAsync(103).Returns(new Account {FileId = 1}); + _fileIdRepository.IdExistsAsync(1).Returns(true); // Act - var result = await _transactionService.AddTransactionsFromCsvAsync(filePath); + var result = await _transactionService.AddTransactionsFromCsvAsync(filePath, 1); // Assert Assert.True(result.Succeed); @@ -79,14 +82,15 @@ public async Task AddTransactionsFromCsvAsync_ShouldOnlyAddNewTransactions_WhenS _fileReaderService.ReadFromFile(filePath).Returns(transactionCsvModels); _transactionRepository.GetAllIdsAsync().Returns(existingTransactionIds); _transactionRepository.CreateBulkAsync(Arg.Any>()).Returns(Task.CompletedTask); - _accountRepository.GetByIdAsync(101).Returns(new Account()); - _accountRepository.GetByIdAsync(102).Returns(new Account()); - _accountRepository.GetByIdAsync(103).Returns(new Account()); - _accountRepository.GetByIdAsync(104).Returns(new Account()); - _accountRepository.GetByIdAsync(105).Returns(new Account()); + _accountRepository.GetByIdAsync(101).Returns(new Account {FileId = 1}); + _accountRepository.GetByIdAsync(102).Returns(new Account {FileId = 1}); + _accountRepository.GetByIdAsync(103).Returns(new Account {FileId = 1}); + _accountRepository.GetByIdAsync(104).Returns(new Account {FileId = 1}); + _accountRepository.GetByIdAsync(105).Returns(new Account {FileId = 1}); + _fileIdRepository.IdExistsAsync(1).Returns(true); // Act - var result = await _transactionService.AddTransactionsFromCsvAsync(filePath); + var result = await _transactionService.AddTransactionsFromCsvAsync(filePath, 1); // Assert Assert.True(result.Succeed); @@ -110,9 +114,10 @@ public async Task AddTransactionsFromCsvAsync_ShouldReturnFail_WhenExceptionIsTh _fileReaderService .ReadFromFile(filePath) .Throws(new Exception(exceptionMessage)); + _fileIdRepository.IdExistsAsync(1).Returns(false); // Act - var result = await _transactionService.AddTransactionsFromCsvAsync(filePath); + var result = await _transactionService.AddTransactionsFromCsvAsync(filePath, 1); // Assert Assert.False(result.Succeed);