Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 7 additions & 16 deletions src/LinkTracker.Scrapper/Controllers/LinksController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,18 @@ namespace LinkTracker.Scrapper.Controllers;
public class LinksController(ILinkRepository repo) : ControllerBase
{
[HttpGet]
public IActionResult Get([FromHeader(Name = "Tg-Chat-Id")] long chatId, [FromQuery] string? tag = null)
public IActionResult Get(
[FromHeader(Name = "Tg-Chat-Id")] long chatId,
[FromQuery] string? tag = null,
[FromQuery] int offset = 0,
[FromQuery] int limit = 100)
{
if (!repo.ChatExists(chatId))
{
return NotFound("Chat is not registered");
}

var links = repo.GetLinks(chatId).ToList();

if (!string.IsNullOrEmpty(tag))
{
links = links
.Where(l => l.Tags != null && l.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase))
.ToList();
}

var responseLinks = links.Select(l => new LinkResponse(
l.Id,
l.Url,
l.Tags ?? Array.Empty<string>()
)).ToArray();
var responseLinks = repo.GetLinks(chatId, tag, offset, limit).ToArray();

return Ok(new ListLinksResponse(responseLinks, responseLinks.Length));
}
Expand Down Expand Up @@ -66,4 +57,4 @@ public IActionResult Remove([FromHeader(Name = "Tg-Chat-Id")] long chatId, [From

return Ok(req);
}
}
}
58 changes: 58 additions & 0 deletions src/LinkTracker.Scrapper/Controllers/TagsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using LinkTracker.Scrapper.Repositories;
using LinkTracker.Shared.Models;
using Microsoft.AspNetCore.Mvc;

namespace LinkTracker.Scrapper.Controllers;

[ApiController]
[Route("tags")]
public class TagsController(ITagRepository repo) : ControllerBase
{
[HttpGet]
public IActionResult GetAll([FromQuery] int offset = 0, [FromQuery] int limit = 100)
{
var tags = repo.GetAll(offset, limit).ToArray();

return Ok(tags);
}

[HttpGet("{id:long}")]
public IActionResult Get(long id)
{
var tag = repo.Get(id);

return tag is null ? NotFound() : Ok(tag);
}

[HttpPost]
public IActionResult Create([FromBody] CreateTagRequest request)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
return BadRequest("Tag name is required");
}

var tag = repo.Create(request.Name);

return CreatedAtAction(nameof(Get), new { id = tag.Id }, tag);
}

[HttpPut("{id:long}")]
public IActionResult Update(long id, [FromBody] UpdateTagRequest request)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
return BadRequest("Tag name is required");
}

var tag = repo.Update(id, request.Name);

return tag is null ? NotFound() : Ok(tag);
}

[HttpDelete("{id:long}")]
public IActionResult Delete(long id)
{
return repo.Delete(id) ? NoContent() : NotFound();
}
}
4 changes: 0 additions & 4 deletions src/LinkTracker.Scrapper/LinkTracker.Scrapper.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,4 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>

<ItemGroup>
<Folder Include="Repositories\Orm\" />
</ItemGroup>
</Project>
27 changes: 24 additions & 3 deletions src/LinkTracker.Scrapper/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
using LinkTracker.Scrapper.Jobs;
using LinkTracker.Scrapper.Configuration;
using LinkTracker.Scrapper.Database;
using Microsoft.EntityFrameworkCore.Migrations;
using LinkTracker.Scrapper.Repositories.Sql;
using LinkTracker.Scrapper.Repositories.Orm;
using Microsoft.EntityFrameworkCore;
using Npgsql;

var builder = WebApplication.CreateBuilder(args);

Expand All @@ -14,7 +17,25 @@
builder.Services.Configure<DatabaseOptions>(
builder.Configuration.GetSection(DatabaseOptions.SectionName));

builder.Services.AddSingleton<ILinkRepository, InMemoryLinkRepository>();
var databaseOptions = builder.Configuration
.GetSection(DatabaseOptions.SectionName)
.Get<DatabaseOptions>() ?? new DatabaseOptions();

builder.Services.AddSingleton(_ => NpgsqlDataSource.Create(databaseOptions.ConnectionString));

builder.Services.AddDbContext<LinkTrackerDbContext>(options =>
options.UseNpgsql(databaseOptions.ConnectionString));

if (databaseOptions.AccessType.Equals("ORM", StringComparison.OrdinalIgnoreCase))
{
builder.Services.AddScoped<ILinkRepository, OrmLinkRepository>();
builder.Services.AddScoped<ITagRepository, OrmTagRepository>();
}
else
{
builder.Services.AddScoped<ILinkRepository, SqlLinkRepository>();
builder.Services.AddScoped<ITagRepository, SqlTagRepository>();
}

builder.Services.AddHttpClient<GitHubClient>();
builder.Services.AddHttpClient<StackOverflowClient>();
Expand Down Expand Up @@ -53,4 +74,4 @@

app.MapControllers();

app.Run();
app.Run();
242 changes: 242 additions & 0 deletions src/LinkTracker.Scrapper/Repositories/Orm/OrmLinkRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
using LinkTracker.Scrapper.Database;
using LinkTracker.Scrapper.Database.Entities;
using LinkTracker.Shared.Models;
using Microsoft.EntityFrameworkCore;

namespace LinkTracker.Scrapper.Repositories.Orm;

public class OrmLinkRepository(LinkTrackerDbContext dbContext) : ILinkRepository
{
public void AddChat(long chatId)
{
if (dbContext.Chats.Any(chat => chat.Id == chatId))
{
return;
}

dbContext.Chats.Add(new ChatEntity
{
Id = chatId
});

dbContext.SaveChanges();
}

public void RemoveChat(long chatId)
{
var chat = dbContext.Chats.FirstOrDefault(chat => chat.Id == chatId);

if (chat is null)
{
return;
}

dbContext.Chats.Remove(chat);
dbContext.SaveChanges();
}

public bool ChatExists(long chatId)
{
return dbContext.Chats
.AsNoTracking()
.Any(chat => chat.Id == chatId);
}

public LinkResponse? AddLink(long chatId, string url, string[]? tags)
{
var normalizedTags = NormalizeTags(tags);

using var transaction = dbContext.Database.BeginTransaction();

if (!dbContext.Chats.Any(chat => chat.Id == chatId))
{
return null;
}

var link = dbContext.Links.FirstOrDefault(link => link.Url == url);

if (link is null)
{
link = new LinkEntity
{
Url = url,
LastCheckedAt = DateTimeOffset.UtcNow
};

dbContext.Links.Add(link);
dbContext.SaveChanges();
}

var subscriptionExists = dbContext.ChatLinks.Any(chatLink =>
chatLink.ChatId == chatId && chatLink.LinkId == link.Id);

if (subscriptionExists)
{
return null;
}

dbContext.ChatLinks.Add(new ChatLinkEntity
{
ChatId = chatId,
LinkId = link.Id
});

dbContext.SaveChanges();

foreach (var tagName in normalizedTags)
{
var tag = dbContext.Tags.FirstOrDefault(tag => tag.Name == tagName);

if (tag is null)
{
tag = new TagEntity
{
Name = tagName
};

dbContext.Tags.Add(tag);
dbContext.SaveChanges();
}

dbContext.ChatLinkTags.Add(new ChatLinkTagEntity
{
ChatId = chatId,
LinkId = link.Id,
TagId = tag.Id
});
}

dbContext.SaveChanges();
transaction.Commit();

return new LinkResponse(link.Id, link.Url, normalizedTags);
}

public bool RemoveLink(long chatId, string url)
{
var chatLink = dbContext.ChatLinks.FirstOrDefault(chatLink =>
chatLink.ChatId == chatId && chatLink.Link.Url == url);

if (chatLink is null)
{
return false;
}

dbContext.ChatLinks.Remove(chatLink);
return dbContext.SaveChanges() > 0;
}

public IEnumerable<LinkResponse> GetLinks(long chatId, string? tag = null, int offset = 0, int limit = 100)
{
var query = dbContext.ChatLinks
.AsNoTracking()
.Where(chatLink => chatLink.ChatId == chatId);

if (!string.IsNullOrWhiteSpace(tag))
{
var normalizedTag = tag.Trim();

query = query.Where(chatLink =>
chatLink.ChatLinkTags.Any(chatLinkTag =>
EF.Functions.ILike(chatLinkTag.Tag.Name, normalizedTag)));
}

var chatLinks = query
.Include(chatLink => chatLink.Link)
.Include(chatLink => chatLink.ChatLinkTags)
.ThenInclude(chatLinkTag => chatLinkTag.Tag)
.OrderBy(chatLink => chatLink.CreatedAt)
.ThenBy(chatLink => chatLink.LinkId)
.Skip(NormalizeOffset(offset))
.Take(NormalizeLimit(limit))
.AsSplitQuery()
.ToList();

return chatLinks
.Select(chatLink => new LinkResponse(
chatLink.Link.Id,
chatLink.Link.Url,
chatLink.ChatLinkTags
.Select(chatLinkTag => chatLinkTag.Tag.Name)
.OrderBy(tagName => tagName)
.ToArray()))
.ToList();
}

public IEnumerable<(string Url, long[] ChatIds, DateTimeOffset LastUpdate)> GetLinksForUpdate(
int offset = 0,
int limit = 100)
{
var links = dbContext.Links
.AsNoTracking()
.Where(link => link.ChatLinks.Any())
.OrderBy(link => link.Id)
.Skip(NormalizeOffset(offset))
.Take(NormalizeLimit(limit))
.Select(link => new
{
link.Id,
link.Url,
link.LastCheckedAt
})
.ToList();

var linkIds = links.Select(link => link.Id).ToArray();

var chatIdsByLinkId = dbContext.ChatLinks
.AsNoTracking()
.Where(chatLink => linkIds.Contains(chatLink.LinkId))
.Select(chatLink => new
{
chatLink.LinkId,
chatLink.ChatId
})
.ToList()
.GroupBy(chatLink => chatLink.LinkId)
.ToDictionary(
group => group.Key,
group => group
.Select(chatLink => chatLink.ChatId)
.OrderBy(chatId => chatId)
.ToArray());

return links
.Select(link => (
link.Url,
chatIdsByLinkId.GetValueOrDefault(link.Id, Array.Empty<long>()),
link.LastCheckedAt))
.ToList();
}

public void UpdateLastCheckTime(string url, DateTimeOffset lastUpdate)
{
var link = dbContext.Links.FirstOrDefault(link => link.Url == url);

if (link is null)
{
return;
}

link.LastCheckedAt = lastUpdate.ToUniversalTime();
dbContext.SaveChanges();
}

private static string[] NormalizeTags(string[]? tags)
{
return tags?
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Select(tag => tag.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? Array.Empty<string>();
}

private static int NormalizeOffset(int offset)
{
return Math.Max(0, offset);
}

private static int NormalizeLimit(int limit)
{
return Math.Clamp(limit, 1, 1000);
}
}
Loading
Loading