diff --git a/src/LinkTracker.Bot/Clients/IScrapperClient.cs b/src/LinkTracker.Bot/Clients/IScrapperClient.cs index b75bf4e..77862ec 100644 --- a/src/LinkTracker.Bot/Clients/IScrapperClient.cs +++ b/src/LinkTracker.Bot/Clients/IScrapperClient.cs @@ -13,7 +13,7 @@ public interface IScrapperClient [Get("/links")] Task GetLinks([Header("Tg-Chat-Id")] long chatId, [Query] string? tag = null); - + [Delete("/links")] Task RemoveLink([Header("Tg-Chat-Id")] long chatId, [Body] RemoveLinkRequest request); } \ No newline at end of file diff --git a/src/LinkTracker.Bot/Commands/CommandDispatcher.cs b/src/LinkTracker.Bot/Commands/CommandDispatcher.cs index 4373344..9e39fb2 100644 --- a/src/LinkTracker.Bot/Commands/CommandDispatcher.cs +++ b/src/LinkTracker.Bot/Commands/CommandDispatcher.cs @@ -15,7 +15,7 @@ public class CommandDispatcher( public async Task HandleMessageAsync(ITelegramBotClient bot, Message msg, CancellationToken ct) { if (msg.Text is not { } messageText) return; - + var chatId = msg.Chat.Id; var session = stateService.GetSession(chatId); @@ -36,7 +36,7 @@ public async Task HandleMessageAsync(ITelegramBotClient bot, Message msg, Cancel } return; } - + if (session.State != UserState.Idle && commandName != "/track") { stateService.ResetSession(chatId); @@ -80,7 +80,7 @@ private async Task HandleDialogueStep(ITelegramBotClient bot, Message msg, UserS else if (session.State == UserState.TrackAwaitingTags) { var tags = text?.ToLower() == "/skip" ? Array.Empty() : text?.Split(',').Select(t => t.Trim()).ToArray(); - try + try { await scrapperClient.AddLink(chatId, new AddLinkRequest(session.TempUrl!, tags)); await bot.SendMessage(chatId, "Success! Link added.", cancellationToken: ct); diff --git a/src/LinkTracker.Bot/Commands/HelpCommand.cs b/src/LinkTracker.Bot/Commands/HelpCommand.cs index b4c0c79..72d67e9 100644 --- a/src/LinkTracker.Bot/Commands/HelpCommand.cs +++ b/src/LinkTracker.Bot/Commands/HelpCommand.cs @@ -16,7 +16,7 @@ public async Task ExecuteAsync(ITelegramBotClient botClient, Message message, Ca "/help - Show this message\n" + "/list - Show your tracked links\n" + " └Tip: Use '/list ' to filter by category"; - + await botClient.SendMessage(message.Chat.Id, text, cancellationToken: ct); } } \ No newline at end of file diff --git a/src/LinkTracker.Bot/Commands/ListCommand.cs b/src/LinkTracker.Bot/Commands/ListCommand.cs index f534097..3c0f5d5 100644 --- a/src/LinkTracker.Bot/Commands/ListCommand.cs +++ b/src/LinkTracker.Bot/Commands/ListCommand.cs @@ -27,21 +27,21 @@ public async Task ExecuteAsync(ITelegramBotClient bot, Message msg, Cancellation await bot.SendMessage(chatId, "No data received from service.", cancellationToken: ct); return; } - + var linkList = response.Links.ToList(); if (linkList.Count == 0) { - var emptyMsg = tagFilter == null - ? "You are not tracking any links yet. Use /track to add one!" + var emptyMsg = tagFilter == null + ? "You are not tracking any links yet. Use /track to add one!" : $"No links found with tag: *{tagFilter}*"; await bot.SendMessage(chatId, emptyMsg, parseMode: ParseMode.Markdown, cancellationToken: ct); return; } - var header = tagFilter == null - ? "📋 *Your tracked links:*" + var header = tagFilter == null + ? "📋 *Your tracked links:*" : $"📋 *Links with tag '{tagFilter}':*"; var messageLines = linkList.Select((l, i) => @@ -49,8 +49,8 @@ public async Task ExecuteAsync(ITelegramBotClient bot, Message msg, Cancellation var safeUrl = l.Url.Replace("_", "\\_"); var tags = l.Tags ?? Array.Empty(); var safeTags = tags.Select(t => t.Replace("_", "\\_")); - var tagsPart = tags.Length > 0 - ? $" _{string.Join(", ", safeTags)}_" + var tagsPart = tags.Length > 0 + ? $" _{string.Join(", ", safeTags)}_" : ""; return $"{i + 1}. {safeUrl}{tagsPart}"; }); diff --git a/src/LinkTracker.Bot/Commands/StartCommand.cs b/src/LinkTracker.Bot/Commands/StartCommand.cs index 08e3775..b53f2f1 100644 --- a/src/LinkTracker.Bot/Commands/StartCommand.cs +++ b/src/LinkTracker.Bot/Commands/StartCommand.cs @@ -8,7 +8,7 @@ public class StartCommand(IScrapperClient scrapper) : IBotCommand { public string Name => "/start"; public string Description => "Start the bot and register"; - + public async Task ExecuteAsync(ITelegramBotClient bot, Message msg, CancellationToken ct) { await scrapper.RegisterChat(msg.Chat.Id); diff --git a/src/LinkTracker.Bot/Controllers/UpdatesController.cs b/src/LinkTracker.Bot/Controllers/UpdatesController.cs index fb13e3d..d5a197e 100644 --- a/src/LinkTracker.Bot/Controllers/UpdatesController.cs +++ b/src/LinkTracker.Bot/Controllers/UpdatesController.cs @@ -7,7 +7,7 @@ namespace LinkTracker.Bot.Controllers; [ApiController] [Route("updates")] public class UpdatesController( - ITelegramBotClient botClient, + ITelegramBotClient botClient, ILogger logger) : ControllerBase { [HttpPost] diff --git a/src/LinkTracker.Bot/Program.cs b/src/LinkTracker.Bot/Program.cs index 92f5d63..5f079b4 100644 --- a/src/LinkTracker.Bot/Program.cs +++ b/src/LinkTracker.Bot/Program.cs @@ -13,14 +13,14 @@ builder.Services.Configure(builder.Configuration.GetSection(BotOptions.SectionName)); -builder.Services.AddSingleton(sp => +builder.Services.AddSingleton(sp => { var options = sp.GetRequiredService>().Value; return new TelegramBotClient(options.BotToken); }); builder.Services.AddRefitClient() - .ConfigureHttpClient((sp, client) => + .ConfigureHttpClient((sp, client) => { var options = sp.GetRequiredService>().Value; client.BaseAddress = new Uri(options.ScrapperUrl); @@ -40,6 +40,6 @@ var app = builder.Build(); -app.MapControllers(); +app.MapControllers(); app.Run(); \ No newline at end of file diff --git a/src/LinkTracker.Bot/Services/BotHostedService.cs b/src/LinkTracker.Bot/Services/BotHostedService.cs index 3f563e4..5640558 100644 --- a/src/LinkTracker.Bot/Services/BotHostedService.cs +++ b/src/LinkTracker.Bot/Services/BotHostedService.cs @@ -29,11 +29,11 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var botCommands = _commands.Select(c => new BotCommand { - Command = c.Name.TrimStart('/'), + Command = c.Name.TrimStart('/'), Description = c.Description }).ToList(); - try + try { await _botClient.SetMyCommands(botCommands, cancellationToken: stoppingToken); _logger.LogInformation("Bot commands registered successfully in Telegram menu."); @@ -47,7 +47,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { AllowedUpdates = Array.Empty(), }; - + await _botClient.ReceiveAsync( updateHandler: HandleUpdateAsync, HandleErrorAsync, @@ -61,7 +61,7 @@ private async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update return; _logger.LogInformation("Received message from {ChatId}: {Text}", message.Chat.Id, message.Text); - + await _dispatcher.HandleMessageAsync(botClient, message, cancellationToken); } diff --git a/src/LinkTracker.Scrapper/Clients/GitHubClient.cs b/src/LinkTracker.Scrapper/Clients/GitHubClient.cs index c47e0dc..b252e2a 100644 --- a/src/LinkTracker.Scrapper/Clients/GitHubClient.cs +++ b/src/LinkTracker.Scrapper/Clients/GitHubClient.cs @@ -9,9 +9,9 @@ public class GitHubClient(HttpClient httpClient, ILogger logger) try { httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LinkTrackerBot/1.0"); - + var response = await httpClient.GetAsync($"https://api.github.com/repos/{owner}/{repo}"); - + if (!response.IsSuccessStatusCode) { logger.LogWarning("GitHub API made error {Code} to {Owner}/{Repo}", response.StatusCode, owner, repo); @@ -19,7 +19,7 @@ public class GitHubClient(HttpClient httpClient, ILogger logger) } var json = await response.Content.ReadFromJsonAsync(); - + if (json.TryGetProperty("pushed_at", out var dateProp)) { return dateProp.GetDateTimeOffset(); @@ -29,7 +29,7 @@ public class GitHubClient(HttpClient httpClient, ILogger logger) { logger.LogError(ex, "Error GitHub API"); } - + return null; } } \ No newline at end of file diff --git a/src/LinkTracker.Scrapper/Clients/StackOverflowClient.cs b/src/LinkTracker.Scrapper/Clients/StackOverflowClient.cs index 268cb2c..933a64c 100644 --- a/src/LinkTracker.Scrapper/Clients/StackOverflowClient.cs +++ b/src/LinkTracker.Scrapper/Clients/StackOverflowClient.cs @@ -9,7 +9,7 @@ public class StackOverflowClient(HttpClient httpClient, ILogger(); - + if (json.TryGetProperty("items", out var items) && items.GetArrayLength() > 0) { var firstItem = items[0]; diff --git a/src/LinkTracker.Scrapper/Controllers/LinksController.cs b/src/LinkTracker.Scrapper/Controllers/LinksController.cs index a9f6e68..c8393c7 100644 --- a/src/LinkTracker.Scrapper/Controllers/LinksController.cs +++ b/src/LinkTracker.Scrapper/Controllers/LinksController.cs @@ -15,7 +15,7 @@ public IActionResult Get([FromHeader(Name = "Tg-Chat-Id")] long chatId, [FromQue { return NotFound("Chat is not registered"); } - + var links = repo.GetLinks(chatId).ToList(); if (!string.IsNullOrEmpty(tag)) @@ -26,8 +26,8 @@ public IActionResult Get([FromHeader(Name = "Tg-Chat-Id")] long chatId, [FromQue } var responseLinks = links.Select(l => new LinkResponse( - l.Id, - l.Url, + l.Id, + l.Url, l.Tags ?? Array.Empty() )).ToArray(); @@ -63,7 +63,7 @@ public IActionResult Remove([FromHeader(Name = "Tg-Chat-Id")] long chatId, [From { return NotFound("Link is not registered"); } - + return Ok(req); } } \ No newline at end of file diff --git a/src/LinkTracker.Scrapper/Database/LinkTrackerDbContext.cs b/src/LinkTracker.Scrapper/Database/LinkTrackerDbContext.cs index b18ee4c..4f6d441 100644 --- a/src/LinkTracker.Scrapper/Database/LinkTrackerDbContext.cs +++ b/src/LinkTracker.Scrapper/Database/LinkTrackerDbContext.cs @@ -6,13 +6,13 @@ namespace LinkTracker.Scrapper.Database; public class LinkTrackerDbContext(DbContextOptions options) : DbContext(options) { public DbSet Chats => Set(); - + public DbSet Links => Set(); - + public DbSet ChatLinks => Set(); - + public DbSet Tags => Set(); - + public DbSet ChatLinkTags => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -40,19 +40,19 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(link => link.Id) .HasColumnName("id"); - + entity.Property(link => link.Url) .HasColumnName("url") .IsRequired(); - + entity.Property(link => link.LastCheckedAt) .HasColumnName("last_checked_at") .HasDefaultValueSql("NOW()"); - + entity.Property(link => link.CreatedAt) .HasColumnName("created_at") .HasDefaultValueSql("NOW()"); - + entity.HasIndex(link => link.Url) .IsUnique(); }); @@ -133,7 +133,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) chatLinkTag.LinkId }) .OnDelete(DeleteBehavior.Cascade); - + entity.HasOne(chatLinkTag => chatLinkTag.Tag) .WithMany(tag => tag.ChatLinkTags) .HasForeignKey(chatLinkTag => chatLinkTag.TagId) diff --git a/src/LinkTracker.Scrapper/Database/MigrationRunner.cs b/src/LinkTracker.Scrapper/Database/MigrationRunner.cs index 9be8c6f..8aa9524 100644 --- a/src/LinkTracker.Scrapper/Database/MigrationRunner.cs +++ b/src/LinkTracker.Scrapper/Database/MigrationRunner.cs @@ -23,7 +23,7 @@ public static void Run(IServiceProvider services) { throw new InvalidOperationException("Database connection string is not configured."); } - + var migrationsPath = Path.Combine(AppContext.BaseDirectory, "migrations"); if (!Directory.Exists(migrationsPath)) @@ -36,7 +36,7 @@ public static void Run(IServiceProvider services) .WithScriptsFromFileSystem(migrationsPath) .LogToConsole() .Build(); - + var result = upgrader.PerformUpgrade(); if (!result.Successful) diff --git a/src/LinkTracker.Scrapper/LinkTracker.Scrapper.csproj b/src/LinkTracker.Scrapper/LinkTracker.Scrapper.csproj index c206ac9..9fcf772 100644 --- a/src/LinkTracker.Scrapper/LinkTracker.Scrapper.csproj +++ b/src/LinkTracker.Scrapper/LinkTracker.Scrapper.csproj @@ -35,4 +35,8 @@ PreserveNewest + + + + diff --git a/src/LinkTracker.Scrapper/Program.cs b/src/LinkTracker.Scrapper/Program.cs index 4a014bb..7a92368 100644 --- a/src/LinkTracker.Scrapper/Program.cs +++ b/src/LinkTracker.Scrapper/Program.cs @@ -19,16 +19,18 @@ builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); -builder.Services.AddHttpClient("BotClient", client => { +builder.Services.AddHttpClient("BotClient", client => +{ var botUrl = builder.Configuration["BotUrl"] ?? "http://localhost:5100"; client.BaseAddress = new Uri(botUrl); }); -builder.Services.AddQuartz(q => { +builder.Services.AddQuartz(q => +{ var jobKey = new JobKey("LinkUpdaterJob"); - + q.AddJob(opts => opts.WithIdentity(jobKey)); - + q.AddTrigger(opts => opts .ForJob(jobKey) .WithIdentity("LinkUpdaterJob-trigger") @@ -43,7 +45,8 @@ MigrationRunner.Run(app.Services); -if (app.Environment.IsDevelopment()) { +if (app.Environment.IsDevelopment()) +{ app.UseSwagger(); app.UseSwaggerUI(); } diff --git a/src/LinkTracker.Scrapper/Repositories/InMemoryLinkRepository.cs b/src/LinkTracker.Scrapper/Repositories/InMemory/InMemoryLinkRepository.cs similarity index 100% rename from src/LinkTracker.Scrapper/Repositories/InMemoryLinkRepository.cs rename to src/LinkTracker.Scrapper/Repositories/InMemory/InMemoryLinkRepository.cs diff --git a/src/LinkTracker.Scrapper/Repositories/ILinkRepository.cs b/src/LinkTracker.Scrapper/Repositories/Interfaces/ILinkRepository.cs similarity index 100% rename from src/LinkTracker.Scrapper/Repositories/ILinkRepository.cs rename to src/LinkTracker.Scrapper/Repositories/Interfaces/ILinkRepository.cs diff --git a/src/LinkTracker.Scrapper/Repositories/ITagRepository.cs b/src/LinkTracker.Scrapper/Repositories/Interfaces/ITagRepository.cs similarity index 100% rename from src/LinkTracker.Scrapper/Repositories/ITagRepository.cs rename to src/LinkTracker.Scrapper/Repositories/Interfaces/ITagRepository.cs diff --git a/src/LinkTracker.Scrapper/Repositories/Sql/SqlLinkRepository.cs b/src/LinkTracker.Scrapper/Repositories/Sql/SqlLinkRepository.cs new file mode 100644 index 0000000..d6ca247 --- /dev/null +++ b/src/LinkTracker.Scrapper/Repositories/Sql/SqlLinkRepository.cs @@ -0,0 +1,292 @@ +using LinkTracker.Shared.Models; +using Npgsql; +using NpgsqlTypes; + +namespace LinkTracker.Scrapper.Repositories.Sql; + +public class SqlLinkRepository(NpgsqlDataSource dataSource) : ILinkRepository +{ + public void AddChat(long chatId) + { + using var command = dataSource.CreateCommand($"INSERT INTO chats (id) VALUES (@chatId) ON CONFLICT DO NOTHING;"); + + command.Parameters.AddWithValue("chatId", chatId); + command.ExecuteNonQuery(); + } + + public void RemoveChat(long chatId) + { + using var command = dataSource.CreateCommand($"DELETE FROM chats WHERE id = @chatId;"); + + command.Parameters.AddWithValue("chatId", chatId); + command.ExecuteNonQuery(); + } + + public bool ChatExists(long chatId) + { + using var command = dataSource.CreateCommand($"SELECT EXISTS (SELECT 1 FROM chats WHERE id = @chatId);"); + + command.Parameters.AddWithValue("chatId", chatId); + + var result = command.ExecuteScalar(); + return result is bool exists && exists; + } + + public LinkResponse? AddLink(long chatId, string url, string[]? tags) + { + var normalizedTags = NormalizeTags(tags); + + using var connection = dataSource.OpenConnection(); + using var transaction = connection.BeginTransaction(); + + if (!ChatExists(connection, transaction, chatId)) + { + return null; + } + + var linkId = GetOrCreateLinkId(connection, transaction, url); + + var subscriptionCreated = AddChatLink(connection, transaction, chatId, linkId); + if (!subscriptionCreated) + { + return null; + } + + foreach (var tag in normalizedTags) + { + var tagId = GetOrCreateTagId(connection, transaction, tag); + AddChatLinkTag(connection, transaction, chatId, linkId, tagId); + } + + transaction.Commit(); + + return new LinkResponse(linkId, url, normalizedTags); + } + + public bool RemoveLink(long chatId, string url) + { + using var command = dataSource.CreateCommand(""" + DELETE FROM chat_links + USING links + WHERE chat_links.link_id = links.id + AND chat_links.chat_id = @chatId + AND links.url = @url; + """); + + command.Parameters.AddWithValue("chatId", chatId); + command.Parameters.AddWithValue("url", url); + + return command.ExecuteNonQuery() > 0; + } + + public IEnumerable GetLinks(long chatId, string? tag = null, int offset = 0, int limit = 100) + { + using var command = dataSource.CreateCommand(""" + SELECT + links.id, + links.url, + COALESCE( + array_agg(tags.name ORDER BY tags.name) FILTER (WHERE tags.name IS NOT NULL), + ARRAY[]::text[] + ) AS tag_names + FROM chat_links + JOIN links ON links.id = chat_links.link_id + LEFT JOIN chat_link_tags + ON chat_link_tags.chat_id = chat_links.chat_id + AND chat_link_tags.link_id = chat_links.link_id + LEFT JOIN tags ON tags.id = chat_link_tags.tag_id + WHERE chat_links.chat_id = @chatId + AND ( + @tag IS NULL + OR EXISTS ( + SELECT 1 + FROM chat_link_tags filter_chat_link_tags + JOIN tags filter_tags ON filter_tags.id = filter_chat_link_tags.tag_id + WHERE filter_chat_link_tags.chat_id = chat_links.chat_id + AND filter_chat_link_tags.link_id = chat_links.link_id + AND lower(filter_tags.name) = lower(@tag) + ) + ) + GROUP BY links.id, links.url, chat_links.created_at + ORDER BY chat_links.created_at, links.id + LIMIT @limit OFFSET @offset; + """); + + command.Parameters.AddWithValue("chatId", chatId); + command.Parameters.Add("tag", NpgsqlDbType.Text).Value = + string.IsNullOrWhiteSpace(tag) ? DBNull.Value : tag.Trim(); + + AddPaginationParameters(command, offset, limit); + + using var reader = command.ExecuteReader(); + var links = new List(); + + while (reader.Read()) + { + links.Add(new LinkResponse( + reader.GetInt64(0), + reader.GetString(1), + reader.GetFieldValue(2))); + } + + return links; + } + + public IEnumerable<(string Url, long[] ChatIds, DateTimeOffset LastUpdate)> GetLinksForUpdate( + int offset = 0, + int limit = 100) + { + using var command = dataSource.CreateCommand(""" + SELECT + links.url, + array_agg(chat_links.chat_id ORDER BY chat_links.chat_id) AS chat_ids, + links.last_checked_at + FROM links + JOIN chat_links ON chat_links.link_id = links.id + GROUP BY links.id, links.url, links.last_checked_at + ORDER BY links.id + LIMIT @limit OFFSET @offset; + """); + + AddPaginationParameters(command, offset, limit); + + using var reader = command.ExecuteReader(); + var links = new List<(string Url, long[] ChatIds, DateTimeOffset LastUpdate)>(); + + while (reader.Read()) + { + links.Add(( + reader.GetString(0), + reader.GetFieldValue(1), + reader.GetFieldValue(2))); + } + + return links; + } + + public void UpdateLastCheckTime(string url, DateTimeOffset lastUpdate) + { + using var command = dataSource.CreateCommand(""" + UPDATE links + SET last_checked_at = @lastUpdate + WHERE url = @url; + """); + + command.Parameters.AddWithValue("url", url); + command.Parameters.AddWithValue("lastUpdate", lastUpdate.ToUniversalTime()); + command.ExecuteNonQuery(); + } + + private static bool ChatExists(NpgsqlConnection connection, NpgsqlTransaction transaction, long chatId) + { + using var command = new NpgsqlCommand(""" + SELECT EXISTS ( + SELECT 1 + FROM chats + WHERE id = @chatId + ); + """, connection, transaction); + + command.Parameters.AddWithValue("chatId", chatId); + + var result = command.ExecuteScalar(); + return result is bool exists && exists; + } + + private static long GetOrCreateLinkId(NpgsqlConnection connection, NpgsqlTransaction transaction, string url) + { + using var command = new NpgsqlCommand(""" + WITH inserted AS ( + INSERT INTO links (url) + VALUES (@url) + ON CONFLICT (url) DO NOTHING + RETURNING id + ) + SELECT id FROM inserted + UNION ALL + SELECT id FROM links WHERE url = @url + LIMIT 1; + """, connection, transaction); + + command.Parameters.AddWithValue("url", url); + + var result = command.ExecuteScalar() + ?? throw new InvalidOperationException($"Unable to find link with url {url}"); + return (long)result; + } + + private static bool AddChatLink( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + long chatId, + long linkId) + { + using var command = new NpgsqlCommand(""" + INSERT INTO chat_links (chat_id, link_id) + VALUES (@chatId, @linkId) + ON CONFLICT DO NOTHING; + """, connection, transaction); + + command.Parameters.AddWithValue("chatId", chatId); + command.Parameters.AddWithValue("linkId", linkId); + + return command.ExecuteNonQuery() > 0; + } + + private static long GetOrCreateTagId(NpgsqlConnection connection, NpgsqlTransaction transaction, string name) + { + using var command = new NpgsqlCommand(""" + WITH inserted AS ( + INSERT INTO tags (name) + VALUES (@name) + ON CONFLICT (name) DO NOTHING + RETURNING id + ) + SELECT id FROM inserted + UNION ALL + SELECT id FROM tags WHERE name = @name + LIMIT 1; + """, connection, transaction); + + command.Parameters.AddWithValue("name", name); + + var result = command.ExecuteScalar() + ?? throw new InvalidOperationException($"Tag id was not returned from db"); + return (long)result; + } + + private static void AddChatLinkTag( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + long chatId, + long linkId, + long tagId) + { + using var command = new NpgsqlCommand(""" + INSERT INTO chat_link_tags (chat_id, link_id, tag_id) + VALUES (@chatId, @linkId, @tagId) + ON CONFLICT DO NOTHING; + """, connection, transaction); + + command.Parameters.AddWithValue("chatId", chatId); + command.Parameters.AddWithValue("linkId", linkId); + command.Parameters.AddWithValue("tagId", tagId); + + command.ExecuteNonQuery(); + } + + private static string[] NormalizeTags(string[]? tags) + { + return tags? + .Where(tag => !string.IsNullOrWhiteSpace(tag)) + .Select(tag => tag.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() ?? Array.Empty(); + } + + private static void AddPaginationParameters(NpgsqlCommand command, int offset, int limit) + { + command.Parameters.AddWithValue("offset", Math.Max(0, offset)); + command.Parameters.AddWithValue("limit", Math.Clamp(limit, 1, 1000)); + } +} \ No newline at end of file diff --git a/src/LinkTracker.Scrapper/Repositories/Sql/SqlTagRepository.cs b/src/LinkTracker.Scrapper/Repositories/Sql/SqlTagRepository.cs new file mode 100644 index 0000000..5a44b0a --- /dev/null +++ b/src/LinkTracker.Scrapper/Repositories/Sql/SqlTagRepository.cs @@ -0,0 +1,87 @@ +using LinkTracker.Shared.Models; +using Npgsql; + +namespace LinkTracker.Scrapper.Repositories.Sql; + +public class SqlTagRepository(NpgsqlDataSource dataSource) : ITagRepository +{ + public TagResponse Create(string name) + { + const string sql = + """ + WITH inserted AS ( + INSERT INTO tags (name) + VALUES (@name) + ON CONFLICT (name) DO NOTHING + RETURNING id, name + ) + SELECT id, name FROM inserted + UNION ALL + SELECT id, name FROM tags WHERE name = @name + LIMIT 1; + """; + + using var command = dataSource.CreateCommand(sql); + command.Parameters.AddWithValue("name", name.Trim()); + + using var reader = command.ExecuteReader(); + reader.Read(); + + return new TagResponse(reader.GetInt64(0), reader.GetString(1)); + } + + public TagResponse? Get(long id) + { + using var command = dataSource.CreateCommand($"SELECT id, name FROM tags WHERE id = @id"); + + command.Parameters.AddWithValue("id", id); + + using var reader = command.ExecuteReader(); + + return reader.Read() ? new TagResponse(reader.GetInt64(0), reader.GetString(1)) : null; + } + + public IEnumerable GetAll(int offset = 0, int limit = 100) + { + using var command = dataSource.CreateCommand($"SELECT id, name FROM tags ORDER BY id LIMIT @limit OFFSET @offset"); + + AddPaginationParameters(command, offset, limit); + + using var reader = command.ExecuteReader(); + var tags = new List(); + + while (reader.Read()) + { + tags.Add(new TagResponse(reader.GetInt64(0), reader.GetString(1))); + } + + return tags; + } + + public TagResponse? Update(long id, string name) + { + using var command = dataSource.CreateCommand($"UPDATE tags SET name = @name WHERE id = @id RETURNING id, name"); + + command.Parameters.AddWithValue("id", id); + command.Parameters.AddWithValue("name", name.Trim()); + + using var reader = command.ExecuteReader(); + + return reader.Read() ? new TagResponse(reader.GetInt64(0), reader.GetString(1)) : null; + } + + public bool Delete(long id) + { + using var command = dataSource.CreateCommand($"DELETE FROM tags WHERE id = @id"); + + command.Parameters.AddWithValue("id", id); + + return command.ExecuteNonQuery() > 0; + } + + private static void AddPaginationParameters(NpgsqlCommand command, int offset, int limit) + { + command.Parameters.AddWithValue("offset", Math.Max(0, offset)); + command.Parameters.AddWithValue("limit", Math.Clamp(limit, 1, 1000)); + } +} \ No newline at end of file