diff --git a/Backend.cs b/Backend.cs index 1887402..d75d60d 100644 --- a/Backend.cs +++ b/Backend.cs @@ -10,6 +10,7 @@ builder.Services.AddSwaggerGen(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddCors(options => { diff --git a/Controller/UserInterestController.cs b/Controller/UserInterestController.cs new file mode 100644 index 0000000..eeb35a8 --- /dev/null +++ b/Controller/UserInterestController.cs @@ -0,0 +1,66 @@ +using Backend.DatabaseHandler; +using Backend.UserInterests; +using Microsoft.AspNetCore.Mvc; + +namespace Backend.Controllers; + +[ApiController] +[Route("api/user-interest")] +public sealed class UserInterestController : ControllerBase +{ + private readonly IUserInterestStore _store; + + public UserInterestController(IUserInterestStore store) + { + _store = store; + } + + [HttpGet("fetch")] + public async Task> FetchAll( + [FromQuery] string user, + [FromQuery] string type) + { + try + { + List items = await _store.FetchAllAsync(user, type); + return Ok(new UserInterestFetchResponse + { + Items = items + }); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpPost("add")] + public async Task> Add( + [FromBody] UserInterestModifyRequest request) + { + try + { + bool success = await _store.AddAsync(request.User, request.Type, request.Value); + return Ok(new BoolResponse { Success = success }); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpDelete("remove")] + public async Task> Remove( + [FromBody] UserInterestModifyRequest request) + { + try + { + bool success = await _store.RemoveAsync(request.User, request.Type, request.Value); + return Ok(new BoolResponse { Success = success }); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } +} \ No newline at end of file diff --git a/DatabaseHandler/HistoricalMarketDataCache.cs b/DatabaseHandler/IHistoricalMarketDataCache.cs similarity index 100% rename from DatabaseHandler/HistoricalMarketDataCache.cs rename to DatabaseHandler/IHistoricalMarketDataCache.cs diff --git a/DatabaseHandler/IUserInterestCache.cs b/DatabaseHandler/IUserInterestCache.cs new file mode 100644 index 0000000..f9ed3f7 --- /dev/null +++ b/DatabaseHandler/IUserInterestCache.cs @@ -0,0 +1,8 @@ +namespace Backend.DatabaseHandler; + +public interface IUserInterestStore +{ + Task> FetchAllAsync(string user, string type); + Task AddAsync(string user, string type, string value); + Task RemoveAsync(string user, string type, string value); +} \ No newline at end of file diff --git a/DatabaseHandler/PostgresHistoricalMarketDataCache.cs b/DatabaseHandler/PostgresHistoricalMarketDataStore.cs similarity index 100% rename from DatabaseHandler/PostgresHistoricalMarketDataCache.cs rename to DatabaseHandler/PostgresHistoricalMarketDataStore.cs diff --git a/DatabaseHandler/PostgresUserInterestStore.cs b/DatabaseHandler/PostgresUserInterestStore.cs new file mode 100644 index 0000000..dffecd3 --- /dev/null +++ b/DatabaseHandler/PostgresUserInterestStore.cs @@ -0,0 +1,141 @@ +using Npgsql; + +namespace Backend.DatabaseHandler; + +public sealed class PostgresUserInterestStore : IUserInterestStore +{ + private readonly string _connectionString; + + public PostgresUserInterestStore(IConfiguration configuration) + { + _connectionString = configuration.GetConnectionString("MarketDataDb") + ?? throw new InvalidOperationException("Missing connection string: MarketDataDb"); + } + + public async Task> FetchAllAsync(string user, string type) + { + ValidateUser(user); + + string normalizedType = NormalizeType(type); + string sql = normalizedType == "symbol" + ? """ + SELECT symbol + FROM user_interest_symbols + WHERE username = @user + ORDER BY symbol ASC; + """ + : """ + SELECT topic + FROM user_interest_topics + WHERE username = @user + ORDER BY topic ASC; + """; + + var results = new List(); + + await using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + + await using var cmd = new NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("user", user.Trim()); + + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + results.Add(reader.GetString(0)); + } + + return results; + } + + public async Task AddAsync(string user, string type, string value) + { + ValidateUser(user); + ValidateValue(value); + + string normalizedType = NormalizeType(type); + string normalizedValue = normalizedType == "symbol" + ? value.Trim().ToUpperInvariant() + : value.Trim(); + + string sql = normalizedType == "symbol" + ? """ + INSERT INTO user_interest_symbols (username, symbol) + VALUES (@user, @value) + ON CONFLICT (username, symbol) DO NOTHING; + """ + : """ + INSERT INTO user_interest_topics (username, topic) + VALUES (@user, @value) + ON CONFLICT (username, topic) DO NOTHING; + """; + + await using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + + await using var cmd = new NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("user", user.Trim()); + cmd.Parameters.AddWithValue("value", normalizedValue); + + int rows = await cmd.ExecuteNonQueryAsync(); + return rows > 0; + } + + public async Task RemoveAsync(string user, string type, string value) + { + ValidateUser(user); + ValidateValue(value); + + string normalizedType = NormalizeType(type); + string normalizedValue = normalizedType == "symbol" + ? value.Trim().ToUpperInvariant() + : value.Trim(); + + string sql = normalizedType == "symbol" + ? """ + DELETE FROM user_interest_symbols + WHERE username = @user AND symbol = @value; + """ + : """ + DELETE FROM user_interest_topics + WHERE username = @user AND topic = @value; + """; + + await using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + + await using var cmd = new NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("user", user.Trim()); + cmd.Parameters.AddWithValue("value", normalizedValue); + + int rows = await cmd.ExecuteNonQueryAsync(); + return rows > 0; + } + + private static string NormalizeType(string type) + { + string normalized = type.Trim().ToLowerInvariant(); + return normalized switch + { + "symbol" => "symbol", + "topic" => "topic", + _ => throw new ArgumentException("Type must be either 'symbol' or 'topic'.") + }; + } + + private static void ValidateUser(string user) + { + if (string.IsNullOrWhiteSpace(user)) + { + throw new ArgumentException("User cannot be empty."); + } + } + + private static void ValidateValue(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Value cannot be empty."); + } + } +} \ No newline at end of file diff --git a/UserInterests/UserInterests.cs b/UserInterests/UserInterests.cs new file mode 100644 index 0000000..6271da8 --- /dev/null +++ b/UserInterests/UserInterests.cs @@ -0,0 +1,24 @@ +namespace Backend.UserInterests; + +public sealed class UserInterestFetchRequest +{ + public string User { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; // "symbol" or "topic" +} + +public sealed class UserInterestModifyRequest +{ + public string User { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; // "symbol" or "topic" + public string Value { get; set; } = string.Empty; // symbol or topic +} + +public sealed class UserInterestFetchResponse +{ + public List Items { get; set; } = new(); +} + +public sealed class BoolResponse +{ + public bool Success { get; set; } +} \ No newline at end of file