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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
TELEGRAM_BOT_TOKEN=your-telegram-bot-token

POSTGRES_DB=linktracker
POSTGRES_USER=linktracker
POSTGRES_PASSWORD=linktracker
30 changes: 27 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
version: '3.8'

services:
bot:
build:
Expand All @@ -23,4 +21,30 @@ services:
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ASPNETCORE_HTTP_PORTS=8080
- BotUrl=http://bot:8080
- BotUrl=http://bot:8080
- Database__AccessType=SQL
- Database__ConnectionString=Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}
- Database__RunMigrations=true
depends_on:
postgres:
condition: service_healthy

postgres:
image: postgres:16
container_name: linktracker-postgres
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5433:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10

volumes:
postgres-data:
39 changes: 39 additions & 0 deletions migrations/001_initial_schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
CREATE TABLE IF NOT EXISTS chats (
id BIGINT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS links (
id BIGSERIAL PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
last_checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS chat_links (
chat_id BIGINT NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
link_id BIGINT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (chat_id, link_id)
);

CREATE TABLE IF NOT EXISTS tags (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS chat_link_tags (
chat_id BIGINT NOT NULL,
link_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (chat_id, link_id, tag_id),
FOREIGN KEY (chat_id, link_id)
REFERENCES chat_links(chat_id, link_id)
ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS ix_links_url ON links(url);
CREATE INDEX IF NOT EXISTS ix_chat_links_link_id ON chat_links(link_id);
CREATE INDEX IF NOT EXISTS ix_chat_link_tags_tag_id ON chat_link_tags(tag_id);
CREATE INDEX IF NOT EXISTS ix_tags_name ON tags(name);
12 changes: 12 additions & 0 deletions src/LinkTracker.Scrapper/Configuration/DatabaseOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace LinkTracker.Scrapper.Configuration;

public sealed class DatabaseOptions
{
public const string SectionName = "Database";

public string AccessType { get; init; } = "SQL";

public string ConnectionString { get; init; } = string.Empty;

public bool RunMigrations { get; init; } = true;
}
10 changes: 10 additions & 0 deletions src/LinkTracker.Scrapper/Database/Entities/ChatEntity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace LinkTracker.Scrapper.Database.Entities;

public class ChatEntity
{
public long Id { get; set; }

public DateTimeOffset CreatedAt { get; set; }

public ICollection<ChatLinkEntity> ChatLinks { get; set; } = new List<ChatLinkEntity>();
}
16 changes: 16 additions & 0 deletions src/LinkTracker.Scrapper/Database/Entities/ChatLinkEntity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace LinkTracker.Scrapper.Database.Entities;

public class ChatLinkEntity
{
public long ChatId { get; set; }

public long LinkId { get; set; }

public DateTimeOffset CreatedAt { get; set; }

public ChatEntity Chat { get; set; } = null!;

public LinkEntity Link { get; set; } = null!;

public ICollection<ChatLinkTagEntity> ChatLinkTags { get; set; } = new List<ChatLinkTagEntity>();
}
14 changes: 14 additions & 0 deletions src/LinkTracker.Scrapper/Database/Entities/ChatLinkTagEntity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace LinkTracker.Scrapper.Database.Entities;

public class ChatLinkTagEntity
{
public long ChatId { get; set; }

public long LinkId { get; set; }

public long TagId { get; set; }

public ChatLinkEntity ChatLink { get; set; } = null!;

public TagEntity Tag { get; set; } = null!;
}
14 changes: 14 additions & 0 deletions src/LinkTracker.Scrapper/Database/Entities/LinkEntity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace LinkTracker.Scrapper.Database.Entities;

public class LinkEntity
{
public long Id { get; set; }

public string Url { get; set; } = string.Empty;

public DateTimeOffset LastCheckedAt { get; set; }

public DateTimeOffset CreatedAt { get; set; }

public ICollection<ChatLinkEntity> ChatLinks { get; set; } = new List<ChatLinkEntity>();
}
12 changes: 12 additions & 0 deletions src/LinkTracker.Scrapper/Database/Entities/TagEntity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace LinkTracker.Scrapper.Database.Entities;

public class TagEntity
{
public long Id { get; set; }

public string Name { get; set; } = string.Empty;

public DateTimeOffset CreatedAt { get; set; }

public ICollection<ChatLinkTagEntity> ChatLinkTags { get; set; } = new List<ChatLinkTagEntity>();
}
143 changes: 143 additions & 0 deletions src/LinkTracker.Scrapper/Database/LinkTrackerDbContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
using Microsoft.EntityFrameworkCore;
using LinkTracker.Scrapper.Database.Entities;

namespace LinkTracker.Scrapper.Database;

public class LinkTrackerDbContext(DbContextOptions<LinkTrackerDbContext> options) : DbContext(options)
{
public DbSet<ChatEntity> Chats => Set<ChatEntity>();

public DbSet<LinkEntity> Links => Set<LinkEntity>();

public DbSet<ChatLinkEntity> ChatLinks => Set<ChatLinkEntity>();

public DbSet<TagEntity> Tags => Set<TagEntity>();

public DbSet<ChatLinkTagEntity> ChatLinkTags => Set<ChatLinkTagEntity>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ChatEntity>(entity =>
{
entity.ToTable("chats");

entity.HasKey(chat => chat.Id);

entity.Property(chat => chat.Id)
.HasColumnName("id")
.ValueGeneratedNever();

entity.Property(chat => chat.CreatedAt)
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
});

modelBuilder.Entity<LinkEntity>(entity =>
{
entity.ToTable("links");

entity.HasKey(link => link.Id);

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();
});

modelBuilder.Entity<ChatLinkEntity>(entity =>
{
entity.ToTable("chat_links");

entity.HasKey(chatLink => new { chatLink.ChatId, chatLink.LinkId });

entity.Property(chatLink => chatLink.ChatId)
.HasColumnName("chat_id");

entity.Property(chatLink => chatLink.LinkId)
.HasColumnName("link_id");

entity.Property(chatLink => chatLink.CreatedAt)
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");

entity.HasOne(chatLink => chatLink.Chat)
.WithMany(chat => chat.ChatLinks)
.HasForeignKey(chatLink => chatLink.ChatId)
.OnDelete(DeleteBehavior.Cascade);

entity.HasOne(chatLink => chatLink.Link)
.WithMany(link => link.ChatLinks)
.HasForeignKey(chatLink => chatLink.LinkId)
.OnDelete(DeleteBehavior.Cascade);
});

modelBuilder.Entity<TagEntity>(entity =>
{
entity.ToTable("tags");

entity.HasKey(tag => tag.Id);

entity.Property(tag => tag.Id)
.HasColumnName("id");

entity.Property(tag => tag.Name)
.HasColumnName("name")
.IsRequired();

entity.Property(tag => tag.CreatedAt)
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");

entity.HasIndex(tag => tag.Name)
.IsUnique();
});

modelBuilder.Entity<ChatLinkTagEntity>(entity =>
{
entity.ToTable("chat_link_tags");

entity.HasKey(chatLinkTag => new
{
chatLinkTag.ChatId,
chatLinkTag.LinkId,
chatLinkTag.TagId
});

entity.Property(chatLinkTag => chatLinkTag.ChatId)
.HasColumnName("chat_id");

entity.Property(chatLinkTag => chatLinkTag.LinkId)
.HasColumnName("link_id");

entity.Property(chatLinkTag => chatLinkTag.TagId)
.HasColumnName("tag_id");

entity.HasOne(chatLinkTag => chatLinkTag.ChatLink)
.WithMany(chatLink => chatLink.ChatLinkTags)
.HasForeignKey(chatLinkTag => new
{
chatLinkTag.ChatId,
chatLinkTag.LinkId
})
.OnDelete(DeleteBehavior.Cascade);

entity.HasOne(chatLinkTag => chatLinkTag.Tag)
.WithMany(tag => tag.ChatLinkTags)
.HasForeignKey(chatLinkTag => chatLinkTag.TagId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}
47 changes: 47 additions & 0 deletions src/LinkTracker.Scrapper/Database/MigrationRunner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using DbUp;
using LinkTracker.Scrapper.Configuration;
using Microsoft.Extensions.Options;

namespace LinkTracker.Scrapper.Database;

public static class MigrationRunner
{
public static void Run(IServiceProvider services)
{
using var scope = services.CreateScope();

var options = scope.ServiceProvider
.GetRequiredService<IOptions<DatabaseOptions>>()
.Value;

if (!options.RunMigrations)
{
return;
}

if (string.IsNullOrWhiteSpace(options.ConnectionString))
{
throw new InvalidOperationException("Database connection string is not configured.");
}

var migrationsPath = Path.Combine(AppContext.BaseDirectory, "migrations");

if (!Directory.Exists(migrationsPath))
{
throw new DirectoryNotFoundException($"Migrations directory not found: {migrationsPath}");
}

var upgrader = DeployChanges.To
.PostgresqlDatabase(options.ConnectionString)
.WithScriptsFromFileSystem(migrationsPath)
.LogToConsole()
.Build();

var result = upgrader.PerformUpgrade();

if (!result.Successful)
{
throw new Exception($"Failed to upgrade migrations: {result.Error}");
}
}
}
12 changes: 11 additions & 1 deletion src/LinkTracker.Scrapper/LinkTracker.Scrapper.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.8" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.16.1" />
<PackageReference Include="Refit" Version="10.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="System.Net.Http.Json" Version="10.0.3" />
<PackageReference Include="Npgsql" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.14" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
<PackageReference Include="dbup-postgresql" Version="7.0.1" />
</ItemGroup>

<ItemGroup>
Expand All @@ -25,4 +29,10 @@
</Content>
</ItemGroup>

<ItemGroup>
<Content Include="..\..\migrations\**\*.sql">
<Link>migrations\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
Loading
Loading