diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..61a88bc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +**/bin/ +**/obj/ +**/node_modules/ +**/.angular/ +**/dist/ +EventAggregator/events.db +EventAggregator/appsettings.json +.git/ +.vscode/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..67d5f31 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,30 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // F5 → збирає .NET → запускає Angular → відкриває браузер на localhost:4200 + "name": "🚀 Full Stack", + "type": "coreclr", + "request": "launch", + + // Спочатку збирає бекенд + запускає Angular (чекає поки Angular готовий) + "preLaunchTask": "serve: frontend", + + // Запускає скомпільований .NET з дебагером + "program": "${workspaceFolder}/EventAggregator/bin/Debug/net9.0/EventAggregator.dll", + "args": [], + "cwd": "${workspaceFolder}/EventAggregator", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + + // Коли .NET виведе "Now listening on:" — автоматично відкриє браузер на :4200 + "serverReadyAction": { + "action": "openExternally", + "pattern": "Now listening on:", + "uriFormat": "http://localhost:4200" + } + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..fee0eb8 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,48 @@ +{ + "version": "2.0.0", + "tasks": [ + + // ── 1. Збірка бекенду ────────────────────────────────────────────────────── + { + "label": "build: backend", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/EventAggregator/EventAggregator.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile", + "group": "build", + "presentation": { "reveal": "silent" } + }, + + // ── 2. Angular dev-сервер (запускається після збірки бекенду) ────────────── + { + "label": "serve: frontend", + "type": "shell", + "command": "npm start", + "options": { + "cwd": "${workspaceFolder}/event-aggregator-client" + }, + "dependsOn": ["build: backend"], + "isBackground": true, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "label": "Angular" + }, + "problemMatcher": { + "owner": "angular", + "pattern": [{ "regexp": "^(.*)$", "message": 1 }], + "background": { + "activeOnStart": true, + "beginsPattern": "Building", + "endsPattern": "localhost:4200" + } + } + } + + ] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c9165ac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# ── Stage 1: Build Angular ──────────────────────────────────────────────── +FROM node:20-alpine AS angular-build +WORKDIR /client +COPY event-aggregator-client/package*.json ./ +RUN npm ci --legacy-peer-deps +COPY event-aggregator-client/ ./ +RUN npm run build -- --configuration production + +# ── Stage 2: Build .NET ─────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS dotnet-build +WORKDIR /src +COPY EventAggregator/ ./ +# Copy Angular build output into wwwroot +COPY --from=angular-build /client/dist/event-aggregator-client/browser ./wwwroot +RUN dotnet publish -c Release -o /app/publish + +# ── Stage 3: Runtime ────────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/aspnet:9.0 +WORKDIR /app +COPY --from=dotnet-build /app/publish ./ + +# SQLite db lives in a persistent volume mounted at /data +ENV DB_PATH=/data/events.db +EXPOSE 8080 +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production + +ENTRYPOINT ["dotnet", "EventAggregator.dll"] diff --git a/EventAggregator/Controllers/AdminController.cs b/EventAggregator/Controllers/AdminController.cs index 95ff4da..5d309e2 100644 --- a/EventAggregator/Controllers/AdminController.cs +++ b/EventAggregator/Controllers/AdminController.cs @@ -7,29 +7,36 @@ namespace EventAggregator.API.Controllers; [ApiController] [Route("api/[controller]")] -[Authorize] +[Authorize(Policy = "AdminOnly")] public class AdminController : ControllerBase { private readonly AppDbContext _db; public AdminController(AppDbContext db) => _db = db; + // ── RSS ─────────────────────────────────────────────────────────────────── + + [HttpPatch("feeds/{id}/toggle")] + public async Task ToggleFeed(int id) + { + var source = await _db.FeedSources.FindAsync(id); + if (source is null) return NotFound(); + source.IsActive = !source.IsActive; + await _db.SaveChangesAsync(); + return Ok(new { source.Id, source.IsActive }); + } + /// Загальна статистика + дані по кожному джерелу [HttpGet("stats")] public async Task GetStats() { - var totalEvents = await _db.Events.CountAsync(); - var totalUsers = await _db.Users.CountAsync(); + var totalEvents = await _db.Events.CountAsync(); + var totalUsers = await _db.Users.CountAsync(); var activeSources = await _db.FeedSources.CountAsync(s => s.IsActive); var sources = await _db.FeedSources .Select(s => new { - s.Id, - s.Name, - s.Category, - s.Language, - s.IsActive, - s.LastFetched, + s.Id, s.Name, s.Category, s.Language, s.IsActive, s.LastFetched, EventCount = s.Events.Count(), }) .OrderByDescending(s => s.EventCount) @@ -37,4 +44,74 @@ public async Task GetStats() return Ok(new { totalEvents, totalUsers, activeSources, sources }); } + + // ── Users ───────────────────────────────────────────────────────────────── + + [HttpGet("users")] + public async Task GetUsers() + { + var users = await _db.Users + .Select(u => new + { + u.Id, u.FirstName, u.LastName, u.Email, + u.IsBanned, u.ReportFrequency, + u.TelegramNotificationsEnabled, u.TelegramChatId, + FilterCount = u.UserFilters.Count(), + SavedCount = u.UserEventStatuses.Count(s => s.Status == "Interesting"), + CalendarCount = u.UserEventStatuses.Count(s => s.IsInCalendar), + }) + .OrderBy(u => u.Id) + .ToListAsync(); + + return Ok(users); + } + + [HttpGet("users/{id}")] + public async Task GetUser(int id) + { + var user = await _db.Users + .Include(u => u.UserFilters).ThenInclude(uf => uf.Filter) + .FirstOrDefaultAsync(u => u.Id == id); + + if (user is null) return NotFound(); + + return Ok(new + { + user.Id, user.FirstName, user.LastName, user.Email, + user.IsBanned, user.ReportFrequency, user.LastReportSent, + user.TwoFactorEnabled, + user.TelegramNotificationsEnabled, user.TelegramUsername, user.TelegramChatId, + Filters = user.UserFilters.Select(uf => new { uf.Filter.Id, uf.Filter.Name, uf.Filter.Type }), + SavedCount = await _db.UserEventStatuses.CountAsync(s => s.UserId == id && s.Status == "Interesting"), + CalendarCount = await _db.UserEventStatuses.CountAsync(s => s.UserId == id && s.IsInCalendar), + }); + } + + [HttpPost("users/{id}/toggle-ban")] + public async Task ToggleBan(int id) + { + var user = await _db.Users.FindAsync(id); + if (user is null) return NotFound(); + user.IsBanned = !user.IsBanned; + await _db.SaveChangesAsync(); + return Ok(new { user.Id, user.IsBanned }); + } + + [HttpDelete("users/{id}")] + public async Task DeleteUser(int id) + { + var user = await _db.Users + .Include(u => u.UserFilters) + .Include(u => u.UserEventStatuses) + .FirstOrDefaultAsync(u => u.Id == id); + + if (user is null) return NotFound(); + + _db.UserFilters.RemoveRange(user.UserFilters); + _db.UserEventStatuses.RemoveRange(user.UserEventStatuses); + _db.Users.Remove(user); + await _db.SaveChangesAsync(); + + return NoContent(); + } } diff --git a/EventAggregator/Controllers/AuthController.cs b/EventAggregator/Controllers/AuthController.cs index 9fd0304..0a0b304 100644 --- a/EventAggregator/Controllers/AuthController.cs +++ b/EventAggregator/Controllers/AuthController.cs @@ -54,6 +54,9 @@ public async Task Login(LoginRequest req) if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash)) return Unauthorized(new { message = "Невірний email або пароль" }); + if (user.IsBanned) + return Unauthorized(new { message = "Ваш акаунт заблоковано. Зв'яжіться з адміністратором: adamyocardium@gmail.com" }); + if (user.TwoFactorEnabled) return Ok(new { requiresTwoFactor = true, userId = user.Id }); diff --git a/EventAggregator/Controllers/DigestController.cs b/EventAggregator/Controllers/DigestController.cs index 572b0b8..607cf95 100644 --- a/EventAggregator/Controllers/DigestController.cs +++ b/EventAggregator/Controllers/DigestController.cs @@ -28,7 +28,7 @@ public async Task SendAll() return Ok(new { message = "Дайджести оброблено" }); } - /// Примусово надіслати дайджест конкретному користувачу (для тестування) + /// Надіслати дайджест на пошту (кнопка «Надіслати зараз») [HttpPost("send/{userId:int}")] public async Task SendToUser(int userId) { @@ -41,8 +41,40 @@ public async Task SendToUser(int userId) try { - await _digestService.SendDigestAsync(user); - return Ok(new { message = $"Дайджест надіслано на {user.Email}" }); + var count = await _digestService.SendNowAsync(user, telegramOnly: false); + return Ok(new { message = $"Дайджест надіслано на {user.Email} ({count} подій)" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + return StatusCode(500, new { error = ex.Message }); + } + } + + /// Надіслати дайджест тільки в Telegram (кнопка «У Telegram») + [HttpPost("send-telegram/{userId:int}")] + public async Task SendTelegramToUser(int userId) + { + var user = await _db.Users + .Include(u => u.UserFilters) + .ThenInclude(uf => uf.Filter) + .FirstOrDefaultAsync(u => u.Id == userId); + + if (user is null) return NotFound(); + if (!user.TelegramChatId.HasValue) + return BadRequest(new { error = "Telegram не підключено. Спочатку надішліть /start боту." }); + + try + { + var count = await _digestService.SendNowAsync(user, telegramOnly: true); + return Ok(new { message = $"Дайджест надіслано в Telegram ({count} подій)" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); } catch (Exception ex) { diff --git a/EventAggregator/Controllers/FeedsController.cs b/EventAggregator/Controllers/FeedsController.cs index f94b91d..8bce2ca 100644 --- a/EventAggregator/Controllers/FeedsController.cs +++ b/EventAggregator/Controllers/FeedsController.cs @@ -39,6 +39,6 @@ public async Task RefreshOne(int id) if (source is null) return NotFound(); await _feedService.RefreshFeedAsync(source); - return Ok(new { message = $"Стрічку «{source.Name}» оновлено" }); + return Ok(new { message = $"Стрічку «{source.Name}» оновлено", lastFetched = source.LastFetched }); } } diff --git a/EventAggregator/Controllers/KeywordsController.cs b/EventAggregator/Controllers/KeywordsController.cs new file mode 100644 index 0000000..05899b5 --- /dev/null +++ b/EventAggregator/Controllers/KeywordsController.cs @@ -0,0 +1,58 @@ +using EventAggregator.API.Models; +using EventAggregator.Data; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; + +namespace EventAggregator.API.Controllers; + +[ApiController] +[Route("api/users/{userId:int}/keywords")] +[Authorize] +public class KeywordsController : ControllerBase +{ + private readonly AppDbContext _db; + public KeywordsController(AppDbContext db) => _db = db; + + [HttpGet] + public async Task GetKeywords(int userId) + { + var keywords = await _db.UserKeywords + .Where(k => k.UserId == userId) + .OrderBy(k => k.Keyword) + .Select(k => new { k.Id, k.Keyword }) + .ToListAsync(); + return Ok(keywords); + } + + [HttpPost] + public async Task AddKeyword(int userId, [FromBody] AddKeywordRequest req) + { + var keyword = req.Keyword.Trim().ToLower(); + if (string.IsNullOrWhiteSpace(keyword)) + return BadRequest(new { error = "Ключове слово не може бути порожнім" }); + + var exists = await _db.UserKeywords + .AnyAsync(k => k.UserId == userId && k.Keyword == keyword); + if (exists) + return Conflict(new { error = "Таке ключове слово вже є" }); + + var entry = new UserKeyword { UserId = userId, Keyword = keyword }; + _db.UserKeywords.Add(entry); + await _db.SaveChangesAsync(); + return Ok(new { entry.Id, entry.Keyword }); + } + + [HttpDelete("{id:int}")] + public async Task DeleteKeyword(int userId, int id) + { + var kw = await _db.UserKeywords.FirstOrDefaultAsync(k => k.Id == id && k.UserId == userId); + if (kw is null) return NotFound(); + _db.UserKeywords.Remove(kw); + await _db.SaveChangesAsync(); + return NoContent(); + } +} + +public record AddKeywordRequest(string Keyword); diff --git a/EventAggregator/Controllers/UsersController.cs b/EventAggregator/Controllers/UsersController.cs index 72950af..ba8d015 100644 --- a/EventAggregator/Controllers/UsersController.cs +++ b/EventAggregator/Controllers/UsersController.cs @@ -57,10 +57,12 @@ public async Task Update(int id, UpdateUserRequest req) var user = await _db.Users.FindAsync(id); if (user is null) return NotFound(); - user.FirstName = req.FirstName ?? user.FirstName; - user.LastName = req.LastName ?? user.LastName; - user.PhotoUrl = req.PhotoUrl ?? user.PhotoUrl; - user.ReportFrequency = req.ReportFrequency ?? user.ReportFrequency; + user.FirstName = req.FirstName ?? user.FirstName; + user.LastName = req.LastName ?? user.LastName; + user.PhotoUrl = req.PhotoUrl ?? user.PhotoUrl; + user.ReportFrequency = req.ReportFrequency ?? user.ReportFrequency; + user.PreferredLanguage = req.PreferredLanguage ?? user.PreferredLanguage; + user.PreferredCategories = req.PreferredCategories ?? user.PreferredCategories; await _db.SaveChangesAsync(); return NoContent(); @@ -259,6 +261,33 @@ public async Task RemoveEventStatus(int id, int eventId) // ── Helpers ────────────────────────────────────────────────────────────── + // ── Telegram ───────────────────────────────────────────────────────────── + + /// Зберегти Telegram-налаштування користувача + [HttpPatch("{id:int}/telegram")] + public async Task UpdateTelegram(int id, UpdateTelegramRequest req) + { + var user = await _db.Users.FindAsync(id); + if (user is null) return NotFound(); + + // Strip leading @ if user typed it + var username = req.TelegramUsername?.Trim().TrimStart('@').ToLower(); + + // If username changed — reset chat link so user needs to /start again + if (user.TelegramUsername != username) + { + user.TelegramUsername = username; + user.TelegramChatId = null; + } + + user.TelegramNotificationsEnabled = req.TelegramNotificationsEnabled; + await _db.SaveChangesAsync(); + + return Ok(new { user.TelegramChatId }); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + private static object MapUser(User u, IEnumerable filters) => new { u.Id, @@ -268,10 +297,16 @@ public async Task RemoveEventStatus(int id, int eventId) u.PhotoUrl, u.ReportFrequency, u.LastReportSent, + u.TelegramNotificationsEnabled, + u.TelegramUsername, + u.TelegramChatId, + u.PreferredLanguage, + u.PreferredCategories, Filters = filters.Select(f => new { f.Id, f.Name, f.Type }) }; } public record CreateUserRequest(string FirstName, string LastName, string Email, string? ReportFrequency); -public record UpdateUserRequest(string? FirstName, string? LastName, string? PhotoUrl, string? ReportFrequency); +public record UpdateUserRequest(string? FirstName, string? LastName, string? PhotoUrl, string? ReportFrequency, string? PreferredLanguage, string? PreferredCategories); +public record UpdateTelegramRequest(bool TelegramNotificationsEnabled, string? TelegramUsername); public record SetEventStatusRequest(string? Status, bool IsInCalendar); diff --git a/EventAggregator/Data/AppDbContext.cs b/EventAggregator/Data/AppDbContext.cs index e2814c5..6a93372 100644 --- a/EventAggregator/Data/AppDbContext.cs +++ b/EventAggregator/Data/AppDbContext.cs @@ -13,6 +13,7 @@ public AppDbContext(DbContextOptions options) : base(options) { } public DbSet UserFilters => Set(); public DbSet UserEventStatuses => Set(); public DbSet FeedSources => Set(); + public DbSet UserKeywords => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/EventAggregator/Data/DbSeeder.cs b/EventAggregator/Data/DbSeeder.cs index a6ab899..78b0485 100644 --- a/EventAggregator/Data/DbSeeder.cs +++ b/EventAggregator/Data/DbSeeder.cs @@ -15,28 +15,24 @@ private static void SeedFeedSources(AppDbContext db) if (db.FeedSources.Any()) return; db.FeedSources.AddRange( - // --- Новини --- - new FeedSource { Name = "Українська правда", FeedUrl = "https://www.pravda.com.ua/rss/view_news/", Category = "news", Language = "uk" }, - new FeedSource { Name = "LIGA.net", FeedUrl = "https://www.liga.net/en/news/rss.xml", Category = "news", Language = "en" }, - new FeedSource { Name = "AIN.EN", FeedUrl = "https://en.ain.ua/feed/", Category = "tech", Language = "en" }, - new FeedSource { Name = "dev.ua", FeedUrl = "https://dev.ua/rss", Category = "tech", Language = "uk" }, - new FeedSource { Name = "BBC News", FeedUrl = "http://feeds.bbci.co.uk/news/rss.xml", Category = "news", Language = "en" }, - new FeedSource { Name = "The Guardian", FeedUrl = "https://www.theguardian.com/world/rss", Category = "news", Language = "en" }, - new FeedSource { Name = "MIT News", FeedUrl = "https://news.mit.edu/rss/feed", Category = "science", Language = "en" }, + // --- Новини (перевірено — RSS коректний) --- + new FeedSource { Name = "Українська правда", FeedUrl = "https://www.pravda.com.ua/rss/view_news/", Category = "news", Language = "uk", IsActive = true }, + new FeedSource { Name = "BBC News", FeedUrl = "https://feeds.bbci.co.uk/news/rss.xml", Category = "news", Language = "en", IsActive = true }, + new FeedSource { Name = "The Guardian", FeedUrl = "https://www.theguardian.com/world/rss", Category = "news", Language = "en", IsActive = true }, + new FeedSource { Name = "Reuters", FeedUrl = "https://feeds.reuters.com/reuters/topNews", Category = "news", Language = "en", IsActive = true }, - // --- IT-події --- - new FeedSource { Name = "DOU Calendar", FeedUrl = "https://dou.ua/calendar/feed/", Category = "events", Language = "uk" }, + // --- IT / Технології --- + new FeedSource { Name = "dev.ua", FeedUrl = "https://dev.ua/rss", Category = "tech", Language = "uk", IsActive = true }, + new FeedSource { Name = "AIN.UA", FeedUrl = "https://ain.ua/feed/", Category = "tech", Language = "uk", IsActive = true }, + new FeedSource { Name = "MIT News", FeedUrl = "https://news.mit.edu/rss/feed", Category = "science", Language = "en", IsActive = true }, + new FeedSource { Name = "Hacker News", FeedUrl = "https://hnrss.org/frontpage", Category = "tech", Language = "en", IsActive = true }, - // --- Академічні події --- - new FeedSource { Name = "Stanford Events", FeedUrl = "https://events.stanford.edu/rss", Category = "events", Language = "en" }, - new FeedSource { Name = "Harvard Gazette", FeedUrl = "https://news.harvard.edu/gazette/feed/harvard-events/", Category = "events", Language = "en" }, + // --- IT-події --- + new FeedSource { Name = "DOU Calendar", FeedUrl = "https://dou.ua/calendar/feed/", Category = "events", Language = "uk", IsActive = true }, - // --- Культурні / концертні події --- - new FeedSource { Name = "Афіша Запоріжжя", FeedUrl = "https://afisha.zp.ua/docs/feedrss/", Category = "events", Language = "uk" }, - new FeedSource { Name = "AfishaLviv Концерти", FeedUrl = "https://afishalviv.net/category/koncerti-ta-festivali/feed/", Category = "events", Language = "uk" }, - new FeedSource { Name = "LvivOnline", FeedUrl = "https://lviv-online.com/ua/feed/", Category = "events", Language = "uk" }, - new FeedSource { Name = "OBX Things Events", FeedUrl = "https://www.obxthings.com/events.rss", Category = "events", Language = "en" }, - new FeedSource { Name = "Kingston Live", FeedUrl = "https://kingstonlive.ca/calendar/rss", Category = "events", Language = "en" } + // --- Культурні / місцеві події --- + new FeedSource { Name = "AfishaLviv Концерти", FeedUrl = "https://afishalviv.net/category/koncerti-ta-festivali/feed/", Category = "events", Language = "uk", IsActive = true }, + new FeedSource { Name = "LvivOnline", FeedUrl = "https://lviv-online.com/ua/feed/", Category = "events", Language = "uk", IsActive = true } ); db.SaveChanges(); diff --git a/EventAggregator/EventAggregator.csproj b/EventAggregator/EventAggregator.csproj index 64914e3..fa2193d 100644 --- a/EventAggregator/EventAggregator.csproj +++ b/EventAggregator/EventAggregator.csproj @@ -22,6 +22,7 @@ + diff --git a/EventAggregator/Migrations/20260528162150_AddTelegramFields.Designer.cs b/EventAggregator/Migrations/20260528162150_AddTelegramFields.Designer.cs new file mode 100644 index 0000000..01e3f8b --- /dev/null +++ b/EventAggregator/Migrations/20260528162150_AddTelegramFields.Designer.cs @@ -0,0 +1,301 @@ +// +using System; +using EventAggregator.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EventAggregator.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260528162150_AddTelegramFields")] + partial class AddTelegramFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("EventAggregator.API.Models.Event", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("ContentHtml") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EventDate") + .HasColumnType("TEXT"); + + b.Property("FeedSourceId") + .HasColumnType("INTEGER"); + + b.Property("ImageUrl") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("MaxPeople") + .HasColumnType("INTEGER"); + + b.Property("PublishedDate") + .HasColumnType("TEXT"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FeedSourceId"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.FeedSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FeedUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Language") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("FeedSources"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.Filter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SearchKeyword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Filters"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastReportSent") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PhotoUrl") + .HasColumnType("TEXT"); + + b.Property("ReportFrequency") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TelegramChatId") + .HasColumnType("INTEGER"); + + b.Property("TelegramNotificationsEnabled") + .HasColumnType("INTEGER"); + + b.Property("TelegramUsername") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorSecret") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserEventStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("IsInCalendar") + .HasColumnType("INTEGER"); + + b.Property("MarkedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EventId"); + + b.HasIndex("UserId"); + + b.ToTable("UserEventStatuses"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserFilter", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("FilterId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "FilterId"); + + b.HasIndex("FilterId"); + + b.ToTable("UserFilters"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.Event", b => + { + b.HasOne("EventAggregator.API.Models.FeedSource", "FeedSource") + .WithMany("Events") + .HasForeignKey("FeedSourceId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("FeedSource"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserEventStatus", b => + { + b.HasOne("EventAggregator.API.Models.Event", "Event") + .WithMany("UserEventStatuses") + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EventAggregator.API.Models.User", "User") + .WithMany("UserEventStatuses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Event"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserFilter", b => + { + b.HasOne("EventAggregator.API.Models.Filter", "Filter") + .WithMany("UserFilters") + .HasForeignKey("FilterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EventAggregator.API.Models.User", "User") + .WithMany("UserFilters") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Filter"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.Event", b => + { + b.Navigation("UserEventStatuses"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.FeedSource", b => + { + b.Navigation("Events"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.Filter", b => + { + b.Navigation("UserFilters"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.User", b => + { + b.Navigation("UserEventStatuses"); + + b.Navigation("UserFilters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EventAggregator/Migrations/20260528162150_AddTelegramFields.cs b/EventAggregator/Migrations/20260528162150_AddTelegramFields.cs new file mode 100644 index 0000000..1611df1 --- /dev/null +++ b/EventAggregator/Migrations/20260528162150_AddTelegramFields.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EventAggregator.Migrations +{ + /// + public partial class AddTelegramFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TelegramChatId", + table: "Users", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "TelegramNotificationsEnabled", + table: "Users", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "TelegramUsername", + table: "Users", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "TelegramChatId", + table: "Users"); + + migrationBuilder.DropColumn( + name: "TelegramNotificationsEnabled", + table: "Users"); + + migrationBuilder.DropColumn( + name: "TelegramUsername", + table: "Users"); + } + } +} diff --git a/EventAggregator/Migrations/20260528191942_AddIsBanned.Designer.cs b/EventAggregator/Migrations/20260528191942_AddIsBanned.Designer.cs new file mode 100644 index 0000000..5e08a65 --- /dev/null +++ b/EventAggregator/Migrations/20260528191942_AddIsBanned.Designer.cs @@ -0,0 +1,304 @@ +// +using System; +using EventAggregator.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EventAggregator.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260528191942_AddIsBanned")] + partial class AddIsBanned + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("EventAggregator.API.Models.Event", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("ContentHtml") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EventDate") + .HasColumnType("TEXT"); + + b.Property("FeedSourceId") + .HasColumnType("INTEGER"); + + b.Property("ImageUrl") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("MaxPeople") + .HasColumnType("INTEGER"); + + b.Property("PublishedDate") + .HasColumnType("TEXT"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FeedSourceId"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.FeedSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FeedUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Language") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("FeedSources"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.Filter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SearchKeyword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Filters"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastReportSent") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PhotoUrl") + .HasColumnType("TEXT"); + + b.Property("ReportFrequency") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TelegramChatId") + .HasColumnType("INTEGER"); + + b.Property("TelegramNotificationsEnabled") + .HasColumnType("INTEGER"); + + b.Property("TelegramUsername") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorSecret") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserEventStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("IsInCalendar") + .HasColumnType("INTEGER"); + + b.Property("MarkedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EventId"); + + b.HasIndex("UserId"); + + b.ToTable("UserEventStatuses"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserFilter", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("FilterId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "FilterId"); + + b.HasIndex("FilterId"); + + b.ToTable("UserFilters"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.Event", b => + { + b.HasOne("EventAggregator.API.Models.FeedSource", "FeedSource") + .WithMany("Events") + .HasForeignKey("FeedSourceId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("FeedSource"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserEventStatus", b => + { + b.HasOne("EventAggregator.API.Models.Event", "Event") + .WithMany("UserEventStatuses") + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EventAggregator.API.Models.User", "User") + .WithMany("UserEventStatuses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Event"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserFilter", b => + { + b.HasOne("EventAggregator.API.Models.Filter", "Filter") + .WithMany("UserFilters") + .HasForeignKey("FilterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EventAggregator.API.Models.User", "User") + .WithMany("UserFilters") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Filter"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.Event", b => + { + b.Navigation("UserEventStatuses"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.FeedSource", b => + { + b.Navigation("Events"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.Filter", b => + { + b.Navigation("UserFilters"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.User", b => + { + b.Navigation("UserEventStatuses"); + + b.Navigation("UserFilters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EventAggregator/Migrations/20260528191942_AddIsBanned.cs b/EventAggregator/Migrations/20260528191942_AddIsBanned.cs new file mode 100644 index 0000000..6ca4764 --- /dev/null +++ b/EventAggregator/Migrations/20260528191942_AddIsBanned.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EventAggregator.Migrations +{ + /// + public partial class AddIsBanned : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsBanned", + table: "Users", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsBanned", + table: "Users"); + } + } +} diff --git a/EventAggregator/Migrations/20260531233959_AddUserKeywords.Designer.cs b/EventAggregator/Migrations/20260531233959_AddUserKeywords.Designer.cs new file mode 100644 index 0000000..b63f597 --- /dev/null +++ b/EventAggregator/Migrations/20260531233959_AddUserKeywords.Designer.cs @@ -0,0 +1,338 @@ +// +using System; +using EventAggregator.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EventAggregator.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260531233959_AddUserKeywords")] + partial class AddUserKeywords + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("EventAggregator.API.Models.Event", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("ContentHtml") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EventDate") + .HasColumnType("TEXT"); + + b.Property("FeedSourceId") + .HasColumnType("INTEGER"); + + b.Property("ImageUrl") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("MaxPeople") + .HasColumnType("INTEGER"); + + b.Property("PublishedDate") + .HasColumnType("TEXT"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FeedSourceId"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.FeedSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FeedUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Language") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("FeedSources"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.Filter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SearchKeyword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Filters"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastReportSent") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PhotoUrl") + .HasColumnType("TEXT"); + + b.Property("ReportFrequency") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TelegramChatId") + .HasColumnType("INTEGER"); + + b.Property("TelegramNotificationsEnabled") + .HasColumnType("INTEGER"); + + b.Property("TelegramUsername") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorSecret") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserEventStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("IsInCalendar") + .HasColumnType("INTEGER"); + + b.Property("MarkedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EventId"); + + b.HasIndex("UserId"); + + b.ToTable("UserEventStatuses"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserFilter", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("FilterId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "FilterId"); + + b.HasIndex("FilterId"); + + b.ToTable("UserFilters"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserKeyword", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Keyword") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserKeywords"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.Event", b => + { + b.HasOne("EventAggregator.API.Models.FeedSource", "FeedSource") + .WithMany("Events") + .HasForeignKey("FeedSourceId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("FeedSource"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserEventStatus", b => + { + b.HasOne("EventAggregator.API.Models.Event", "Event") + .WithMany("UserEventStatuses") + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EventAggregator.API.Models.User", "User") + .WithMany("UserEventStatuses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Event"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserFilter", b => + { + b.HasOne("EventAggregator.API.Models.Filter", "Filter") + .WithMany("UserFilters") + .HasForeignKey("FilterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EventAggregator.API.Models.User", "User") + .WithMany("UserFilters") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Filter"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserKeyword", b => + { + b.HasOne("EventAggregator.API.Models.User", "User") + .WithMany("UserKeywords") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.Event", b => + { + b.Navigation("UserEventStatuses"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.FeedSource", b => + { + b.Navigation("Events"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.Filter", b => + { + b.Navigation("UserFilters"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.User", b => + { + b.Navigation("UserEventStatuses"); + + b.Navigation("UserFilters"); + + b.Navigation("UserKeywords"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EventAggregator/Migrations/20260531233959_AddUserKeywords.cs b/EventAggregator/Migrations/20260531233959_AddUserKeywords.cs new file mode 100644 index 0000000..1d7e227 --- /dev/null +++ b/EventAggregator/Migrations/20260531233959_AddUserKeywords.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EventAggregator.Migrations +{ + /// + public partial class AddUserKeywords : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserKeywords", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(type: "INTEGER", nullable: false), + Keyword = table.Column(type: "TEXT", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserKeywords", x => x.Id); + table.ForeignKey( + name: "FK_UserKeywords_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserKeywords_UserId", + table: "UserKeywords", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserKeywords"); + } + } +} diff --git a/EventAggregator/Migrations/20260603162212_AddUserPreferences.Designer.cs b/EventAggregator/Migrations/20260603162212_AddUserPreferences.Designer.cs new file mode 100644 index 0000000..60e2b52 --- /dev/null +++ b/EventAggregator/Migrations/20260603162212_AddUserPreferences.Designer.cs @@ -0,0 +1,344 @@ +// +using System; +using EventAggregator.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EventAggregator.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260603162212_AddUserPreferences")] + partial class AddUserPreferences + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("EventAggregator.API.Models.Event", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("ContentHtml") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EventDate") + .HasColumnType("TEXT"); + + b.Property("FeedSourceId") + .HasColumnType("INTEGER"); + + b.Property("ImageUrl") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("MaxPeople") + .HasColumnType("INTEGER"); + + b.Property("PublishedDate") + .HasColumnType("TEXT"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FeedSourceId"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.FeedSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FeedUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Language") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("FeedSources"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.Filter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SearchKeyword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Filters"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastReportSent") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PhotoUrl") + .HasColumnType("TEXT"); + + b.Property("PreferredCategories") + .HasColumnType("TEXT"); + + b.Property("PreferredLanguage") + .HasColumnType("TEXT"); + + b.Property("ReportFrequency") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TelegramChatId") + .HasColumnType("INTEGER"); + + b.Property("TelegramNotificationsEnabled") + .HasColumnType("INTEGER"); + + b.Property("TelegramUsername") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorSecret") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserEventStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("IsInCalendar") + .HasColumnType("INTEGER"); + + b.Property("MarkedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EventId"); + + b.HasIndex("UserId"); + + b.ToTable("UserEventStatuses"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserFilter", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("FilterId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "FilterId"); + + b.HasIndex("FilterId"); + + b.ToTable("UserFilters"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserKeyword", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Keyword") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserKeywords"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.Event", b => + { + b.HasOne("EventAggregator.API.Models.FeedSource", "FeedSource") + .WithMany("Events") + .HasForeignKey("FeedSourceId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("FeedSource"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserEventStatus", b => + { + b.HasOne("EventAggregator.API.Models.Event", "Event") + .WithMany("UserEventStatuses") + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EventAggregator.API.Models.User", "User") + .WithMany("UserEventStatuses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Event"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserFilter", b => + { + b.HasOne("EventAggregator.API.Models.Filter", "Filter") + .WithMany("UserFilters") + .HasForeignKey("FilterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EventAggregator.API.Models.User", "User") + .WithMany("UserFilters") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Filter"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.UserKeyword", b => + { + b.HasOne("EventAggregator.API.Models.User", "User") + .WithMany("UserKeywords") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.Event", b => + { + b.Navigation("UserEventStatuses"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.FeedSource", b => + { + b.Navigation("Events"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.Filter", b => + { + b.Navigation("UserFilters"); + }); + + modelBuilder.Entity("EventAggregator.API.Models.User", b => + { + b.Navigation("UserEventStatuses"); + + b.Navigation("UserFilters"); + + b.Navigation("UserKeywords"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EventAggregator/Migrations/20260603162212_AddUserPreferences.cs b/EventAggregator/Migrations/20260603162212_AddUserPreferences.cs new file mode 100644 index 0000000..f3960b3 --- /dev/null +++ b/EventAggregator/Migrations/20260603162212_AddUserPreferences.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EventAggregator.Migrations +{ + /// + public partial class AddUserPreferences : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PreferredCategories", + table: "Users", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PreferredLanguage", + table: "Users", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PreferredCategories", + table: "Users"); + + migrationBuilder.DropColumn( + name: "PreferredLanguage", + table: "Users"); + } + } +} diff --git a/EventAggregator/Migrations/AppDbContextModelSnapshot.cs b/EventAggregator/Migrations/AppDbContextModelSnapshot.cs index 260ba84..67a8f9c 100644 --- a/EventAggregator/Migrations/AppDbContextModelSnapshot.cs +++ b/EventAggregator/Migrations/AppDbContextModelSnapshot.cs @@ -139,6 +139,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); + b.Property("IsBanned") + .HasColumnType("INTEGER"); + b.Property("LastName") .IsRequired() .HasColumnType("TEXT"); @@ -153,10 +156,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("PhotoUrl") .HasColumnType("TEXT"); + b.Property("PreferredCategories") + .HasColumnType("TEXT"); + + b.Property("PreferredLanguage") + .HasColumnType("TEXT"); + b.Property("ReportFrequency") .IsRequired() .HasColumnType("TEXT"); + b.Property("TelegramChatId") + .HasColumnType("INTEGER"); + + b.Property("TelegramNotificationsEnabled") + .HasColumnType("INTEGER"); + + b.Property("TelegramUsername") + .HasColumnType("TEXT"); + b.Property("TwoFactorEnabled") .HasColumnType("INTEGER"); @@ -214,6 +232,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UserFilters"); }); + modelBuilder.Entity("EventAggregator.API.Models.UserKeyword", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Keyword") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserKeywords"); + }); + modelBuilder.Entity("EventAggregator.API.Models.Event", b => { b.HasOne("EventAggregator.API.Models.FeedSource", "FeedSource") @@ -262,6 +301,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("EventAggregator.API.Models.UserKeyword", b => + { + b.HasOne("EventAggregator.API.Models.User", "User") + .WithMany("UserKeywords") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("EventAggregator.API.Models.Event", b => { b.Navigation("UserEventStatuses"); @@ -282,6 +332,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("UserEventStatuses"); b.Navigation("UserFilters"); + + b.Navigation("UserKeywords"); }); #pragma warning restore 612, 618 } diff --git a/EventAggregator/Models/User.cs b/EventAggregator/Models/User.cs index 424bfbe..cfc63ef 100644 --- a/EventAggregator/Models/User.cs +++ b/EventAggregator/Models/User.cs @@ -24,6 +24,20 @@ public class User public bool TwoFactorEnabled { get; set; } = false; public string? TwoFactorSecret { get; set; } + public bool IsBanned { get; set; } = false; + + // ── Telegram ────────────────────────────────────────────────────────────── + public bool TelegramNotificationsEnabled { get; set; } = false; + public string? TelegramUsername { get; set; } // введено в профілі (без @) + public long? TelegramChatId { get; set; } // встановлюється після /start + + // ── Preferences ────────────────────────────────────────────────────────── + /// "uk", "en" or null/empty = both languages + public string? PreferredLanguage { get; set; } + /// Comma-separated: "events,news,tech,science" or null/empty = all + public string? PreferredCategories { get; set; } + public ICollection UserFilters { get; set; } = new List(); public ICollection UserEventStatuses { get; set; } = new List(); + public ICollection UserKeywords { get; set; } = new List(); } \ No newline at end of file diff --git a/EventAggregator/Models/UserKeyword.cs b/EventAggregator/Models/UserKeyword.cs new file mode 100644 index 0000000..ea5aace --- /dev/null +++ b/EventAggregator/Models/UserKeyword.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace EventAggregator.API.Models; + +public class UserKeyword +{ + public int Id { get; set; } + + public int UserId { get; set; } + public User User { get; set; } = null!; + + [Required, MaxLength(100)] + public string Keyword { get; set; } = string.Empty; +} diff --git a/EventAggregator/Program.cs b/EventAggregator/Program.cs index db24546..9fb21a6 100644 --- a/EventAggregator/Program.cs +++ b/EventAggregator/Program.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; +using Telegram.Bot; var builder = WebApplication.CreateBuilder(args); @@ -14,8 +15,17 @@ builder.Services.AddOpenApi(); +var dbPath = Environment.GetEnvironmentVariable("DB_PATH") is string envDbPath + ? $"Data Source={envDbPath}" + : builder.Configuration.GetConnectionString("DefaultConnection") ?? "Data Source=events.db"; + +// Ensure the SQLite database directory exists (needed for Railway volumes) +var dbFile = dbPath.Replace("Data Source=", "").Split(';')[0].Trim(); +var dbDir = Path.GetDirectoryName(dbFile); +if (!string.IsNullOrEmpty(dbDir)) + Directory.CreateDirectory(dbDir); builder.Services.AddDbContext(options => - options.UseSqlite("Data Source=events.db")); + options.UseSqlite(dbPath)); builder.Services.AddHttpClient(client => { @@ -29,6 +39,19 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); +// ── Telegram Bot ────────────────────────────────────────────────────────── +var telegramToken = builder.Configuration["Telegram:BotToken"]; +if (!string.IsNullOrWhiteSpace(telegramToken)) +{ + builder.Services.AddSingleton(new TelegramBotClient(telegramToken)); + builder.Services.AddScoped(); + builder.Services.AddHostedService(); +} +else +{ + builder.Services.AddScoped(); +} + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { @@ -45,7 +68,12 @@ }; }); -builder.Services.AddAuthorization(); +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("AdminOnly", policy => + policy.RequireClaim(System.Security.Claims.ClaimTypes.Email, + "adamyocardium@gmail.com")); +}); builder.Services.AddCors(options => options.AddDefaultPolicy(policy => @@ -70,9 +98,21 @@ } app.UseCors(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseDefaultFiles(); + app.UseStaticFiles(); +} + app.UseHttpsRedirection(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); +if (!app.Environment.IsDevelopment()) +{ + app.MapFallbackToFile("index.html"); +} + app.Run(); diff --git a/EventAggregator/Services/DigestService.cs b/EventAggregator/Services/DigestService.cs index 2dad3eb..76da603 100644 --- a/EventAggregator/Services/DigestService.cs +++ b/EventAggregator/Services/DigestService.cs @@ -9,13 +9,69 @@ public class DigestService { private readonly AppDbContext _db; private readonly IEmailService _emailService; + private readonly ITelegramBotService _telegramService; private readonly ILogger _logger; - public DigestService(AppDbContext db, IEmailService emailService, ILogger logger) + public DigestService(AppDbContext db, IEmailService emailService, + ITelegramBotService telegramService, ILogger logger) { - _db = db; - _emailService = emailService; - _logger = logger; + _db = db; + _emailService = emailService; + _telegramService = telegramService; + _logger = logger; + } + + /// + /// Відправка на вимогу (кнопка «Надіслати зараз»). + /// Завжди бере останні 30 днів, повертає кількість надісланих подій. + /// Кидає InvalidOperationException якщо нічого не знайдено. + /// + public async Task SendNowAsync(User user, bool telegramOnly = false) + { + var from = DateTime.UtcNow.AddDays(-30); + + var keywords = user.UserFilters + .Select(uf => uf.Filter.SearchKeyword.ToLower()) + .ToList(); + + var personalKeywords = await _db.UserKeywords + .Where(k => k.UserId == user.Id) + .Select(k => k.Keyword) + .ToListAsync(); + keywords.AddRange(personalKeywords); + + var prefLang = string.IsNullOrWhiteSpace(user.PreferredLanguage) ? null : user.PreferredLanguage; + + var events = await _db.Events + .Include(e => e.FeedSource) + .Where(e => e.PublishedDate >= from) + .Where(e => !keywords.Any() || keywords.Any(k => + e.Title.ToLower().Contains(k) || + e.Description.ToLower().Contains(k))) + .Where(e => prefLang == null || e.FeedSource!.Language == prefLang) + .OrderByDescending(e => e.PublishedDate) + .Take(15) + .ToListAsync(); + + if (events.Count == 0) + throw new InvalidOperationException( + "Не знайдено жодної події за останні 30 днів за вашими підписками. " + + "Спробуйте додати більше ключових слів або категорій."); + + if (!telegramOnly) + { + var subject = "EventAggregator — Дайджест на ваш запит"; + var html = BuildHtml(user, events); + await _emailService.SendAsync(user.Email, subject, html); + } + + if (user.TelegramNotificationsEnabled && user.TelegramChatId.HasValue) + { + var tgText = BuildTelegramText(user, events); + await _telegramService.SendMessageAsync(user.TelegramChatId.Value, tgText); + } + + return events.Count; } public async Task SendAllDueAsync() @@ -44,10 +100,17 @@ public async Task SendDigestAsync(User user) .Select(uf => uf.Filter.SearchKeyword.ToLower()) .ToList(); + // Додаємо персональні ключові слова користувача + var personalKeywords = await _db.UserKeywords + .Where(k => k.UserId == user.Id) + .Select(k => k.Keyword) + .ToListAsync(); + keywords.AddRange(personalKeywords); + var events = await _db.Events .Include(e => e.FeedSource) .Where(e => e.PublishedDate >= from) - .Where(e => keywords.Any(k => + .Where(e => !keywords.Any() || keywords.Any(k => e.Title.ToLower().Contains(k) || e.Description.ToLower().Contains(k))) .OrderByDescending(e => e.PublishedDate) @@ -65,12 +128,58 @@ public async Task SendDigestAsync(User user) await _emailService.SendAsync(user.Email, subject, html); + // Telegram + if (user.TelegramNotificationsEnabled && user.TelegramChatId.HasValue) + { + var tgText = BuildTelegramText(user, events); + await _telegramService.SendMessageAsync(user.TelegramChatId.Value, tgText); + } + user.LastReportSent = DateTime.UtcNow; await _db.SaveChangesAsync(); _logger.LogInformation("Digest sent to {Email}: {Count} events", user.Email, events.Count); } + /// Send digest only to Telegram (called by /digest bot command). + public async Task SendTelegramDigestAsync(User user) + { + if (!user.TelegramChatId.HasValue) return; + + var from = GetFromDate(user.ReportFrequency); + var keywords = user.UserFilters + .Select(uf => uf.Filter.SearchKeyword.ToLower()) + .ToList(); + + var personalKeywords = await _db.UserKeywords + .Where(k => k.UserId == user.Id) + .Select(k => k.Keyword) + .ToListAsync(); + keywords.AddRange(personalKeywords); + + var events = await _db.Events + .Include(e => e.FeedSource) + .Where(e => e.PublishedDate >= from) + .Where(e => !keywords.Any() || keywords.Any(k => + e.Title.ToLower().Contains(k) || + e.Description.ToLower().Contains(k))) + .OrderByDescending(e => e.PublishedDate) + .Take(10) + .ToListAsync(); + + if (events.Count == 0) + { + await _telegramService.SendMessageAsync(user.TelegramChatId.Value, + "ℹ️ За вашими підписками поки що немає нових подій."); + return; + } + + var tgText = BuildTelegramText(user, events); + await _telegramService.SendMessageAsync(user.TelegramChatId.Value, tgText); + _logger.LogInformation("Telegram digest sent to chatId={ChatId}: {Count} events", + user.TelegramChatId.Value, events.Count); + } + public async Task BuildPreviewAsync(User user) { var from = GetFromDate(user.ReportFrequency); @@ -195,6 +304,42 @@ private static string BuildHtml(User user, List events) return sb.ToString(); } + private static string BuildTelegramText(User user, List events) + { + var sb = new StringBuilder(); + + sb.AppendLine($"📅 EventAggregator — {FrequencyLabel(user.ReportFrequency)} дайджест"); + sb.AppendLine(); + sb.AppendLine($"Привіт, {TgEscape(user.FirstName)}! 👋"); + sb.AppendLine("Ось підбірка свіжих подій за вашими підписками:"); + sb.AppendLine(); + + for (int i = 0; i < events.Count; i++) + { + var ev = events[i]; + var source = TgEscape(ev.FeedSource?.Name ?? "Невідоме джерело"); + var date = ev.PublishedDate.ToString("dd.MM.yyyy"); + var cat = string.IsNullOrEmpty(ev.Category) ? "" : $" · {TgEscape(ev.Category)}"; + var desc = ev.Description.Length > 150 + ? ev.Description[..150] + "…" + : ev.Description; + + sb.AppendLine($"{i + 1}. {TgEscape(ev.Title)}"); + sb.AppendLine($"📰 {source} · {date}{cat}"); + sb.AppendLine(TgEscape(desc)); + sb.AppendLine($"🔗 Читати далі"); + sb.AppendLine(); + } + + sb.AppendLine($"Частота розсилки: {FrequencyLabel(user.ReportFrequency)}"); + + return sb.ToString().Trim(); + } + + /// Escapes characters that have special meaning in Telegram HTML mode. + private static string TgEscape(string s) => + s.Replace("&", "&").Replace("<", "<").Replace(">", ">"); + private static string Escape(string s) => s.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """); } diff --git a/EventAggregator/Services/ITelegramBotService.cs b/EventAggregator/Services/ITelegramBotService.cs new file mode 100644 index 0000000..ad39c44 --- /dev/null +++ b/EventAggregator/Services/ITelegramBotService.cs @@ -0,0 +1,6 @@ +namespace EventAggregator.API.Services; + +public interface ITelegramBotService +{ + Task SendMessageAsync(long chatId, string htmlText); +} diff --git a/EventAggregator/Services/NullTelegramBotService.cs b/EventAggregator/Services/NullTelegramBotService.cs new file mode 100644 index 0000000..6ef23ee --- /dev/null +++ b/EventAggregator/Services/NullTelegramBotService.cs @@ -0,0 +1,16 @@ +namespace EventAggregator.API.Services; + +/// No-op implementation used when Telegram:BotToken is not configured. +public class NullTelegramBotService : ITelegramBotService +{ + private readonly ILogger _logger; + + public NullTelegramBotService(ILogger logger) + => _logger = logger; + + public Task SendMessageAsync(long chatId, string htmlText) + { + _logger.LogDebug("Telegram not configured — skipping message to chatId={ChatId}", chatId); + return Task.CompletedTask; + } +} diff --git a/EventAggregator/Services/RssFeedService.cs b/EventAggregator/Services/RssFeedService.cs index f31111b..5fb713b 100644 --- a/EventAggregator/Services/RssFeedService.cs +++ b/EventAggregator/Services/RssFeedService.cs @@ -40,12 +40,47 @@ public async Task RefreshAllFeedsAsync() public async Task RefreshFeedAsync(FeedSource source) { - var stream = await _httpClient.GetStreamAsync(source.FeedUrl); + // ── 1. HTTP-запит з перевіркою статусу ─────────────────────────────── + using var response = await _httpClient.GetAsync(source.FeedUrl); - var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore }; - using var reader = XmlReader.Create(stream, settings); - var feed = SyndicationFeed.Load(reader); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Джерело {Name}: HTTP {Status} — пропускаємо", + source.Name, (int)response.StatusCode); + return; + } + + var contentType = response.Content.Headers.ContentType?.MediaType ?? ""; + if (contentType.Contains("text/html", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Джерело {Name}: повернуло HTML замість RSS — пропускаємо", + source.Name); + return; + } + + // ── 2. Парсинг XML (толерантний до поганих символів та DTD) ────────── + using var stream = await response.Content.ReadAsStreamAsync(); + + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Ignore, + CheckCharacters = false, // ігноруємо неприпустимі символи (0x02 тощо) + IgnoreWhitespace = false, + }; + + SyndicationFeed feed; + try + { + using var reader = XmlReader.Create(stream, settings); + feed = SyndicationFeed.Load(reader); + } + catch (XmlException ex) + { + _logger.LogWarning("Джерело {Name}: некоректний XML — {Msg}", source.Name, ex.Message); + return; + } + // ── 3. Збереження нових подій ───────────────────────────────────────── var existingUrls = (await _db.Events .Where(e => e.FeedSourceId == source.Id) .Select(e => e.SourceUrl) @@ -62,16 +97,16 @@ public async Task RefreshFeedAsync(FeedSource source) newEvents.Add(new Event { - Title = item.Title?.Text ?? string.Empty, + Title = item.Title?.Text ?? string.Empty, Description = item.Summary?.Text ?? string.Empty, - SourceUrl = url, + SourceUrl = url, PublishedDate = item.PublishDate == default ? DateTime.UtcNow : item.PublishDate.UtcDateTime, FeedSourceId = source.Id, - Category = source.Category, - Author = item.Authors.FirstOrDefault()?.Name, - ImageUrl = item.Links + Category = source.Category, + Author = item.Authors.FirstOrDefault()?.Name, + ImageUrl = item.Links .FirstOrDefault(l => l.RelationshipType == "enclosure")?.Uri?.ToString(), }); } diff --git a/EventAggregator/Services/TelegramBotBackgroundService.cs b/EventAggregator/Services/TelegramBotBackgroundService.cs new file mode 100644 index 0000000..d7fd65a --- /dev/null +++ b/EventAggregator/Services/TelegramBotBackgroundService.cs @@ -0,0 +1,175 @@ +using EventAggregator.API.Models; +using EventAggregator.Data; +using Microsoft.EntityFrameworkCore; +using Telegram.Bot; +using Telegram.Bot.Polling; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; + +namespace EventAggregator.API.Services; + +public class TelegramBotBackgroundService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ITelegramBotClient _botClient; + private readonly ILogger _logger; + + public TelegramBotBackgroundService( + IServiceScopeFactory scopeFactory, + ITelegramBotClient botClient, + ILogger logger) + { + _scopeFactory = scopeFactory; + _botClient = botClient; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var options = new ReceiverOptions + { + AllowedUpdates = [UpdateType.Message], + DropPendingUpdates = true, + }; + + _botClient.StartReceiving( + updateHandler: HandleUpdateAsync, + errorHandler: HandleErrorAsync, + receiverOptions: options, + cancellationToken: stoppingToken); + + _logger.LogInformation("Telegram bot started (long polling)"); + + // Keep the service alive until cancellation + await Task.Delay(Timeout.Infinite, stoppingToken).ContinueWith(_ => { }); + } + + // ── Update handler ──────────────────────────────────────────────────────── + + private async Task HandleUpdateAsync( + ITelegramBotClient client, Update update, CancellationToken ct) + { + if (update.Message is not { } msg) return; + if (msg.Text is not { } text) return; + + var chatId = msg.Chat.Id; + var username = msg.From?.Username?.ToLower(); + + if (text.StartsWith("/start")) + await HandleStartAsync(client, chatId, username, ct); + else if (text.StartsWith("/digest")) + await HandleDigestAsync(client, chatId, ct); + else + await client.SendMessage(chatId, + "Привіт! Я — EventAggregator бот 🎭\n\n" + + "Доступні команди:\n" + + "/start — підключити акаунт\n" + + "/digest — отримати дайджест прямо зараз", + cancellationToken: ct); + } + + // ── /start ──────────────────────────────────────────────────────────────── + + private async Task HandleStartAsync( + ITelegramBotClient client, long chatId, string? username, CancellationToken ct) + { + if (string.IsNullOrEmpty(username)) + { + await client.SendMessage(chatId, + "❌ У вашому Telegram-акаунті не налаштовано юзернейм.\n" + + "Додайте @username у налаштуваннях Telegram і спробуйте ще раз.", + cancellationToken: ct); + return; + } + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var user = await db.Users.FirstOrDefaultAsync( + u => u.TelegramUsername != null && + u.TelegramUsername.ToLower() == username, ct); + + if (user is null) + { + await client.SendMessage(chatId, + $"❌ Акаунт з юзернеймом @{username} не знайдено в EventAggregator.\n\n" + + "Переконайтесь, що ви правильно вказали юзернейм у профілі на сайті та увімкнули Telegram-розсилку.", + parseMode: ParseMode.Html, + cancellationToken: ct); + return; + } + + user.TelegramChatId = chatId; + await db.SaveChangesAsync(ct); + + _logger.LogInformation("Telegram linked: userId={UserId} → chatId={ChatId}", user.Id, chatId); + + await client.SendMessage(chatId, + $"✅ Акаунт підключено!\n\n" + + $"Привіт, {Escape(user.FirstName)}! 👋\n" + + $"Тепер ти отримуватимеш персональні дайджести прямо в Telegram.\n\n" + + $"Частота розсилки: {FrequencyLabel(user.ReportFrequency)}\n\n" + + $"Відправ /digest щоб отримати добірку прямо зараз.", + parseMode: ParseMode.Html, + cancellationToken: ct); + } + + // ── /digest ─────────────────────────────────────────────────────────────── + + private async Task HandleDigestAsync( + ITelegramBotClient client, long chatId, CancellationToken ct) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var digestService = scope.ServiceProvider.GetRequiredService(); + + var user = await db.Users + .Include(u => u.UserFilters).ThenInclude(uf => uf.Filter) + .FirstOrDefaultAsync(u => u.TelegramChatId == chatId, ct); + + if (user is null) + { + await client.SendMessage(chatId, + "❌ Акаунт не підключено.\nНадішли /start для підключення.", + cancellationToken: ct); + return; + } + + await client.SendMessage(chatId, "⏳ Формую дайджест...", cancellationToken: ct); + + try + { + await digestService.SendTelegramDigestAsync(user); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending on-demand digest to chatId={ChatId}", chatId); + await client.SendMessage(chatId, + "❌ Помилка формування дайджесту. Спробуйте пізніше.", + cancellationToken: ct); + } + } + + // ── Error handler ───────────────────────────────────────────────────────── + + private Task HandleErrorAsync( + ITelegramBotClient client, Exception ex, + HandleErrorSource source, CancellationToken ct) + { + _logger.LogWarning(ex, "Telegram polling error [{Source}]", source); + return Task.CompletedTask; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static string FrequencyLabel(string f) => f switch + { + "Daily" => "Щоденний", + "Weekly" => "Тижневий", + "Monthly" => "Місячний", + _ => "Персональний", + }; + + private static string Escape(string s) => + s.Replace("&", "&").Replace("<", "<").Replace(">", ">"); +} diff --git a/EventAggregator/Services/TelegramBotService.cs b/EventAggregator/Services/TelegramBotService.cs new file mode 100644 index 0000000..80e4c30 --- /dev/null +++ b/EventAggregator/Services/TelegramBotService.cs @@ -0,0 +1,67 @@ +using Telegram.Bot; +using Telegram.Bot.Types.Enums; + +namespace EventAggregator.API.Services; + +public class TelegramBotService : ITelegramBotService +{ + private readonly ITelegramBotClient _client; + private readonly ILogger _logger; + + public TelegramBotService(ITelegramBotClient client, ILogger logger) + { + _client = client; + _logger = logger; + } + + public async Task SendMessageAsync(long chatId, string htmlText) + { + // Telegram message limit is 4096 chars — split if needed + const int limit = 4000; + + if (htmlText.Length <= limit) + { + await SendChunkAsync(chatId, htmlText); + return; + } + + // Split on double newline boundaries + var parts = SplitText(htmlText, limit); + foreach (var part in parts) + await SendChunkAsync(chatId, part); + } + + private async Task SendChunkAsync(long chatId, string text) + { + try + { + await _client.SendMessage(chatId, text, parseMode: ParseMode.Html); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send Telegram message to chatId={ChatId}", chatId); + } + } + + private static List SplitText(string text, int limit) + { + var parts = new List(); + var lines = text.Split('\n'); + var current = new System.Text.StringBuilder(); + + foreach (var line in lines) + { + if (current.Length + line.Length + 1 > limit) + { + parts.Add(current.ToString().Trim()); + current.Clear(); + } + current.AppendLine(line); + } + + if (current.Length > 0) + parts.Add(current.ToString().Trim()); + + return parts; + } +} diff --git a/EventAggregator/appsettings.Production.json b/EventAggregator/appsettings.Production.json new file mode 100644 index 0000000..154b75b --- /dev/null +++ b/EventAggregator/appsettings.Production.json @@ -0,0 +1,22 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Data Source=/data/events.db" + }, + "Jwt": { + "Issuer": "EventAggregator", + "Audience": "EventAggregator", + "ExpiresInHours": "24" + }, + "RssPoll": { + "IntervalHours": "3" + }, + "Telegram": { + "BotToken": "" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/EventAggregator/appsettings.example.json b/EventAggregator/appsettings.example.json index faed014..faa1ee6 100644 --- a/EventAggregator/appsettings.example.json +++ b/EventAggregator/appsettings.example.json @@ -19,5 +19,8 @@ "Password": "your-app-password", "FromName": "EventAggregator", "FromEmail": "your@gmail.com" + }, + "Telegram": { + "BotToken": "YOUR-BOT-TOKEN-FROM-BOTFATHER" } } diff --git a/EventAggregator/events.db b/EventAggregator/events.db index 9111846..ce6823c 100644 Binary files a/EventAggregator/events.db and b/EventAggregator/events.db differ diff --git a/EventAggregator/events.db-shm b/EventAggregator/events.db-shm deleted file mode 100644 index 9ec1e95..0000000 Binary files a/EventAggregator/events.db-shm and /dev/null differ diff --git a/EventAggregator/events.db-wal b/EventAggregator/events.db-wal deleted file mode 100644 index 4233351..0000000 Binary files a/EventAggregator/events.db-wal and /dev/null differ diff --git a/event-aggregator-client/angular.json b/event-aggregator-client/angular.json index 2f1a28e..28b833e 100644 --- a/event-aggregator-client/angular.json +++ b/event-aggregator-client/angular.json @@ -62,6 +62,12 @@ }, "configurations": { "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.production.ts" + } + ], "budgets": [ { "type": "initial", diff --git a/event-aggregator-client/src/app/app.routes.ts b/event-aggregator-client/src/app/app.routes.ts index 5fccce2..7e88db0 100644 --- a/event-aggregator-client/src/app/app.routes.ts +++ b/event-aggregator-client/src/app/app.routes.ts @@ -1,5 +1,6 @@ import { Routes } from '@angular/router'; import { authGuard } from './core/guards/auth.guard'; +import { adminGuard } from './core/guards/admin.guard'; export const routes: Routes = [ { @@ -32,7 +33,7 @@ export const routes: Routes = [ { path: 'admin', loadComponent: () => import('./features/admin/admin').then(m => m.Admin), - canActivate: [authGuard], + canActivate: [authGuard, adminGuard], }, { path: '**', redirectTo: '' }, ]; diff --git a/event-aggregator-client/src/app/core/guards/admin.guard.ts b/event-aggregator-client/src/app/core/guards/admin.guard.ts new file mode 100644 index 0000000..bd885a0 --- /dev/null +++ b/event-aggregator-client/src/app/core/guards/admin.guard.ts @@ -0,0 +1,32 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { map, catchError, of } from 'rxjs'; +import { AuthService } from '../services/auth.service'; + +const ADMIN_EMAIL = 'adamyocardium@gmail.com'; + +export const adminGuard: CanActivateFn = () => { + const auth = inject(AuthService); + const router = inject(Router); + + const allow = (email: string | undefined) => { + if (email === ADMIN_EMAIL) return true; + router.navigate(['/']); + return false; + }; + + // Якщо юзер вже є в пам'яті — перевіряємо одразу + if (auth.currentUser()) { + return allow(auth.currentUser()!.email); + } + + // Токен є, але currentUser порожній (наприклад, localStorage частково очищено) + // → підтягуємо юзера з API і тоді перевіряємо + return auth.me().pipe( + map(user => allow(user.email)), + catchError(() => { + router.navigate(['/']); + return of(false); + }) + ); +}; diff --git a/event-aggregator-client/src/app/core/models/event.model.ts b/event-aggregator-client/src/app/core/models/event.model.ts index 46c00a8..6424d84 100644 --- a/event-aggregator-client/src/app/core/models/event.model.ts +++ b/event-aggregator-client/src/app/core/models/event.model.ts @@ -7,6 +7,7 @@ export interface EventItem { id: number; title: string; description: string; + contentHtml?: string; sourceUrl: string; imageUrl?: string; publishedDate: string; diff --git a/event-aggregator-client/src/app/core/models/user.model.ts b/event-aggregator-client/src/app/core/models/user.model.ts index 7cf269f..38ca82b 100644 --- a/event-aggregator-client/src/app/core/models/user.model.ts +++ b/event-aggregator-client/src/app/core/models/user.model.ts @@ -8,6 +8,12 @@ export interface User { photoUrl?: string; reportFrequency: string; lastReportSent?: string; + // Telegram + telegramNotificationsEnabled: boolean; + telegramUsername?: string; + telegramChatId?: number; + preferredLanguage?: string; + preferredCategories?: string; filters: Filter[]; } @@ -23,4 +29,11 @@ export interface UpdateUserRequest { lastName?: string; photoUrl?: string; reportFrequency?: string; + preferredLanguage?: string; + preferredCategories?: string; +} + +export interface UpdateTelegramRequest { + telegramNotificationsEnabled: boolean; + telegramUsername?: string; } diff --git a/event-aggregator-client/src/app/core/services/auth.service.ts b/event-aggregator-client/src/app/core/services/auth.service.ts index 2b79db4..030ec1a 100644 --- a/event-aggregator-client/src/app/core/services/auth.service.ts +++ b/event-aggregator-client/src/app/core/services/auth.service.ts @@ -26,7 +26,13 @@ export class AuthService { } login(req: LoginRequest) { - return this.http.post(`${this.base}/login`, req); + return this.http.post(`${this.base}/login`, req).pipe( + tap(res => { + if (!('requiresTwoFactor' in res)) { + this.saveSession(res as AuthResponse); + } + }) + ); } verifyTwoFactor(userId: number, code: string) { diff --git a/event-aggregator-client/src/app/core/services/digest.service.ts b/event-aggregator-client/src/app/core/services/digest.service.ts new file mode 100644 index 0000000..f2a49cf --- /dev/null +++ b/event-aggregator-client/src/app/core/services/digest.service.ts @@ -0,0 +1,17 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; + +@Injectable({ providedIn: 'root' }) +export class DigestService { + private http = inject(HttpClient); + private base = `${environment.apiUrl}/digest`; + + sendNow(userId: number) { + return this.http.post<{ message: string }>(`${this.base}/send/${userId}`, {}); + } + + sendTelegramNow(userId: number) { + return this.http.post<{ message: string }>(`${this.base}/send-telegram/${userId}`, {}); + } +} diff --git a/event-aggregator-client/src/app/core/services/feeds.service.ts b/event-aggregator-client/src/app/core/services/feeds.service.ts index ff1e100..fdf5540 100644 --- a/event-aggregator-client/src/app/core/services/feeds.service.ts +++ b/event-aggregator-client/src/app/core/services/feeds.service.ts @@ -19,6 +19,27 @@ export interface AdminStats { sources: SourceStats[]; } +export interface AdminUser { + id: number; + firstName: string; + lastName: string; + email: string; + isBanned: boolean; + reportFrequency: string; + telegramNotificationsEnabled: boolean; + telegramChatId: number | null; + filterCount: number; + savedCount: number; + calendarCount: number; +} + +export interface AdminUserDetail extends AdminUser { + lastReportSent: string | null; + twoFactorEnabled: boolean; + telegramUsername: string | null; + filters: { id: number; name: string; type: string }[]; +} + @Injectable({ providedIn: 'root' }) export class FeedsService { private http = inject(HttpClient); @@ -33,6 +54,26 @@ export class FeedsService { } refreshOne(id: number) { - return this.http.post<{ message: string }>(`${this.base}/feeds/${id}/refresh`, {}); + return this.http.post<{ message: string; lastFetched: string }>(`${this.base}/feeds/${id}/refresh`, {}); + } + + toggleActive(id: number) { + return this.http.patch<{ id: number; isActive: boolean }>(`${this.base}/admin/feeds/${id}/toggle`, {}); + } + + getUsers() { + return this.http.get(`${this.base}/admin/users`); + } + + getUserDetail(id: number) { + return this.http.get(`${this.base}/admin/users/${id}`); + } + + toggleBan(id: number) { + return this.http.post<{ id: number; isBanned: boolean }>(`${this.base}/admin/users/${id}/toggle-ban`, {}); + } + + deleteUser(id: number) { + return this.http.delete(`${this.base}/admin/users/${id}`); } } diff --git a/event-aggregator-client/src/app/core/services/keywords.service.ts b/event-aggregator-client/src/app/core/services/keywords.service.ts new file mode 100644 index 0000000..3698120 --- /dev/null +++ b/event-aggregator-client/src/app/core/services/keywords.service.ts @@ -0,0 +1,26 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; + +export interface UserKeyword { + id: number; + keyword: string; +} + +@Injectable({ providedIn: 'root' }) +export class KeywordsService { + private http = inject(HttpClient); + private base = environment.apiUrl; + + getAll(userId: number) { + return this.http.get(`${this.base}/users/${userId}/keywords`); + } + + add(userId: number, keyword: string) { + return this.http.post(`${this.base}/users/${userId}/keywords`, { keyword }); + } + + delete(userId: number, id: number) { + return this.http.delete(`${this.base}/users/${userId}/keywords/${id}`); + } +} diff --git a/event-aggregator-client/src/app/core/services/users.service.ts b/event-aggregator-client/src/app/core/services/users.service.ts index 9acb63b..327af12 100644 --- a/event-aggregator-client/src/app/core/services/users.service.ts +++ b/event-aggregator-client/src/app/core/services/users.service.ts @@ -1,6 +1,6 @@ import { inject, Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { CreateUserRequest, UpdateUserRequest, User } from '../models/user.model'; +import { CreateUserRequest, UpdateUserRequest, UpdateTelegramRequest, User } from '../models/user.model'; import { EventItem, EventsResponse, EventStatus } from '../models/event.model'; import { Filter } from '../models/filter.model'; import { environment } from '../../../environments/environment'; @@ -22,6 +22,10 @@ export class UsersService { return this.http.put(`${this.base}/${id}`, req); } + updateTelegram(id: number, req: UpdateTelegramRequest) { + return this.http.patch<{ telegramChatId?: number }>(`${this.base}/${id}/telegram`, req); + } + getFilters(id: number) { return this.http.get(`${this.base}/${id}/filters`); } diff --git a/event-aggregator-client/src/app/features/admin/admin.html b/event-aggregator-client/src/app/features/admin/admin.html index e7850ea..fec046a 100644 --- a/event-aggregator-client/src/app/features/admin/admin.html +++ b/event-aggregator-client/src/app/features/admin/admin.html @@ -52,90 +52,173 @@

admin_panel_settings Адміністрування

- + -

list RSS-джерела

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Джерело - {{ row.name }} - Категорія - {{ row.category }} - Мова - {{ row.language === 'uk' ? '🇺🇦' : '🇬🇧' }} - Подій -
- {{ row.eventCount }} - -
-
Оновлено - - {{ timeAgo(row.lastFetched) }} - - Статус - - {{ row.isActive ? 'Активне' : 'Вимкнено' }} - - - -
+ + + + + + rss_feed RSS-джерела + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Джерело + {{ row.name }} + Категорія + {{ row.category }} + Мова + {{ row.language === 'uk' ? '🇺🇦' : '🇬🇧' }} + Подій +
+ {{ row.eventCount }} + +
+
Оновлено + + {{ timeAgo(row.lastFetched) }} + + Активне + + + +
+
+
+ + + + + people Користувачі + + +
+ @if (usersLoading()) { +
+ } @else { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ {{ initials(row) }} +
+
Ім'я + {{ row.firstName }} {{ row.lastName }} + Email + {{ row.email }} + Фільтри{{ row.filterCount }}Telegram + @if (row.telegramChatId) { + check_circle + } @else { + cancel + } + Статус + + {{ row.isBanned ? 'Заблокований' : 'Активний' }} + + + +
+ } +
+
+ +
} diff --git a/event-aggregator-client/src/app/features/admin/admin.scss b/event-aggregator-client/src/app/features/admin/admin.scss index 55ab0ac..7dae78e 100644 --- a/event-aggregator-client/src/app/features/admin/admin.scss +++ b/event-aggregator-client/src/app/features/admin/admin.scss @@ -136,5 +136,47 @@ &.cat-science { background: #fce8e6 !important; color: #d93025 !important; } } -.chip-active { background: #e6f4ea !important; color: #34a853 !important; font-size: 0.75rem !important; } -.chip-inactive { background: #f5f5f5 !important; color: #9aa0a6 !important; font-size: 0.75rem !important; } +.chip-active { background: #e6f4ea !important; color: #34a853 !important; font-size: 0.75rem !important; } +.chip-inactive { background: #f5f5f5 !important; color: #9aa0a6 !important; font-size: 0.75rem !important; } +.chip-active-user { background: #e6f4ea !important; color: #34a853 !important; font-size: 0.75rem !important; } +.chip-banned { background: #fce8e6 !important; color: #d93025 !important; font-size: 0.75rem !important; } + +// ── Вкладки ─────────────────────────────────────────────────────────────── + +.tab-icon { font-size: 18px; margin-right: 6px; vertical-align: middle; } + +.tab-content { padding-top: 16px; } + +// ── Таблиця користувачів ────────────────────────────────────────────────── + +.users-table { + width: 100%; + + th { font-weight: 600; color: #555; font-size: 0.8rem; } + + .user-email-cell { font-size: 0.85rem; color: #555; } + + .user-row { cursor: pointer; } +} + +.user-avatar-sm { + width: 34px; + height: 34px; + border-radius: 50%; + background: #1a73e8; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 0.8rem; + + &.banned { background: #d93025; } +} + +.tg-icon { + font-size: 18px; + color: #9aa0a6; + + &.connected { color: #34a853; } +} diff --git a/event-aggregator-client/src/app/features/admin/admin.ts b/event-aggregator-client/src/app/features/admin/admin.ts index 72b7d7c..0fc2deb 100644 --- a/event-aggregator-client/src/app/features/admin/admin.ts +++ b/event-aggregator-client/src/app/features/admin/admin.ts @@ -10,7 +10,11 @@ import { MatChipsModule } from '@angular/material/chips'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatDividerModule } from '@angular/material/divider'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { FeedsService, AdminStats, SourceStats } from '../../core/services/feeds.service'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatDialog } from '@angular/material/dialog'; +import { FeedsService, AdminStats, SourceStats, AdminUser } from '../../core/services/feeds.service'; +import { UserDetailDialog } from './user-detail-dialog'; @Component({ selector: 'app-admin', @@ -19,7 +23,7 @@ import { FeedsService, AdminStats, SourceStats } from '../../core/services/feeds DatePipe, DecimalPipe, MatCardModule, MatTableModule, MatButtonModule, MatIconModule, MatProgressSpinnerModule, MatProgressBarModule, MatChipsModule, - MatTooltipModule, MatDividerModule, + MatTooltipModule, MatDividerModule, MatSlideToggleModule, MatTabsModule, ], templateUrl: './admin.html', styleUrl: './admin.scss', @@ -27,15 +31,24 @@ import { FeedsService, AdminStats, SourceStats } from '../../core/services/feeds export class Admin implements OnInit { private feedsService = inject(FeedsService); private snackBar = inject(MatSnackBar); + private dialog = inject(MatDialog); stats = signal(null); loading = signal(true); refreshingAll = signal(false); refreshingId = signal(null); + togglingId = signal(null); - readonly columns = ['name', 'category', 'language', 'events', 'lastFetched', 'status', 'actions']; + users = signal([]); + usersLoading = signal(true); - ngOnInit() { this.loadStats(); } + readonly sourceColumns = ['name', 'category', 'language', 'events', 'lastFetched', 'status', 'actions']; + readonly userColumns = ['avatar', 'name', 'email', 'filters', 'telegram', 'status', 'actions']; + + ngOnInit() { + this.loadStats(); + this.loadUsers(); + } loadStats() { this.loading.set(true); @@ -45,6 +58,14 @@ export class Admin implements OnInit { }); } + loadUsers() { + this.usersLoading.set(true); + this.feedsService.getUsers().subscribe({ + next: u => { this.users.set(u); this.usersLoading.set(false); }, + error: () => this.usersLoading.set(false), + }); + } + refreshAll() { this.refreshingAll.set(true); this.feedsService.refreshAll().subscribe({ @@ -66,7 +87,9 @@ export class Admin implements OnInit { next: r => { this.refreshingId.set(null); this.snackBar.open(r.message, '', { duration: 3000 }); - this.loadStats(); + source.lastFetched = r.lastFetched; + const s = this.stats(); + if (s) this.stats.set({ ...s }); }, error: () => { this.refreshingId.set(null); @@ -75,6 +98,49 @@ export class Admin implements OnInit { }); } + toggleActive(source: SourceStats) { + this.togglingId.set(source.id); + this.feedsService.toggleActive(source.id).subscribe({ + next: r => { + source.isActive = r.isActive; + this.togglingId.set(null); + const s = this.stats(); + if (s) { + s.activeSources += r.isActive ? 1 : -1; + this.stats.set({ ...s }); + } + }, + error: () => { + this.togglingId.set(null); + this.snackBar.open('Помилка зміни статусу', 'ОК', { duration: 3000 }); + }, + }); + } + + openUser(user: AdminUser) { + const ref = this.dialog.open(UserDetailDialog, { + data: { ...user }, + width: '480px', + }); + + ref.afterClosed().subscribe(result => { + if (!result) return; + if (result.action === 'delete') { + this.users.update(list => list.filter(u => u.id !== user.id)); + const s = this.stats(); + if (s) this.stats.set({ ...s, totalUsers: s.totalUsers - 1 }); + } else if (result.action === 'ban') { + this.users.update(list => + list.map(u => u.id === user.id ? { ...u, isBanned: result.isBanned } : u) + ); + } + }); + } + + initials(u: AdminUser) { + return (u.firstName[0] + u.lastName[0]).toUpperCase(); + } + timeAgo(date: string | null): string { if (!date) return 'Ніколи'; const diff = Date.now() - new Date(date).getTime(); diff --git a/event-aggregator-client/src/app/features/admin/user-detail-dialog.html b/event-aggregator-client/src/app/features/admin/user-detail-dialog.html new file mode 100644 index 0000000..7e27639 --- /dev/null +++ b/event-aggregator-client/src/app/features/admin/user-detail-dialog.html @@ -0,0 +1,94 @@ +
+
+
{{ initials }}
+
+

{{ data.firstName }} {{ data.lastName }}

+ {{ data.email }} +
+ + {{ data.isBanned ? 'Заблоковано' : 'Активний' }} + +
+ + + @if (loading()) { +
+ } + + @if (!loading() && detail(); as d) { +
+
+ filter_list + {{ d.filters.length }} + фільтрів +
+
+ star + {{ d.savedCount }} + збережено +
+
+ calendar_today + {{ d.calendarCount }} + у календарі +
+
+ + + +
+
+ Розсилка + {{ d.reportFrequency }} +
+
+ Остання розсилка + {{ d.lastReportSent ? (d.lastReportSent | date:'dd.MM.yyyy HH:mm') : 'Ніколи' }} +
+
+ 2FA + {{ d.twoFactorEnabled ? '✅ Увімкнено' : '❌ Вимкнено' }} +
+
+ Telegram + + @if (d.telegramChatId) { + ✅ @{{ d.telegramUsername }} + } @else if (d.telegramUsername) { + ⏳ @{{ d.telegramUsername }} (очікує /start) + } @else { + — не підключено + } + +
+
+ + @if (d.filters.length > 0) { + +
+

Підписки

+
+ @for (f of d.filters; track f.id) { + {{ f.name }} + } +
+
+ } + } +
+ + + + + + +
diff --git a/event-aggregator-client/src/app/features/admin/user-detail-dialog.scss b/event-aggregator-client/src/app/features/admin/user-detail-dialog.scss new file mode 100644 index 0000000..aebab8b --- /dev/null +++ b/event-aggregator-client/src/app/features/admin/user-detail-dialog.scss @@ -0,0 +1,95 @@ +.user-dialog { + min-width: 420px; +} + +.dialog-header { + display: flex; + align-items: center; + gap: 14px; + padding-bottom: 4px; + + h2 { margin: 0; font-size: 1.1rem; font-weight: 600; } +} + +.user-avatar { + width: 48px; + height: 48px; + border-radius: 50%; + background: #1a73e8; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 1.1rem; + flex-shrink: 0; +} + +.user-email { font-size: 0.82rem; color: #888; } + +.user-title { flex: 1; } + +.chip-active-user { background: #e6f4ea !important; color: #34a853 !important; font-size: 0.75rem !important; } +.chip-banned { background: #fce8e6 !important; color: #d93025 !important; font-size: 0.75rem !important; } + +.dialog-spinner { display: flex; justify-content: center; padding: 32px 0; } + +.detail-stats { + display: flex; + gap: 24px; + padding: 16px 0; +} + +.dstat { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + flex: 1; + + mat-icon { color: #1a73e8; font-size: 20px; } + .dstat-val { font-size: 1.3rem; font-weight: 700; } + .dstat-lbl { font-size: 0.75rem; color: #888; } +} + +.detail-rows { + padding: 12px 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.detail-row { + display: flex; + gap: 8px; + font-size: 0.88rem; +} + +.detail-label { + color: #888; + min-width: 130px; +} + +.filters-section { + padding: 12px 0 4px; +} + +.filters-title { + margin: 0 0 8px; + font-size: 0.82rem; + color: #888; + font-weight: 500; +} + +.filters-wrap { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.filter-chip { + font-size: 0.75rem !important; + height: 24px !important; + background: #e8f0fe !important; + color: #1a73e8 !important; +} diff --git a/event-aggregator-client/src/app/features/admin/user-detail-dialog.ts b/event-aggregator-client/src/app/features/admin/user-detail-dialog.ts new file mode 100644 index 0000000..e981100 --- /dev/null +++ b/event-aggregator-client/src/app/features/admin/user-detail-dialog.ts @@ -0,0 +1,71 @@ +import { Component, inject, signal, OnInit } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { FeedsService, AdminUser, AdminUserDetail } from '../../core/services/feeds.service'; + +@Component({ + selector: 'app-user-detail-dialog', + standalone: true, + imports: [ + DatePipe, + MatDialogModule, MatButtonModule, MatChipsModule, + MatIconModule, MatProgressSpinnerModule, MatDividerModule, + ], + templateUrl: './user-detail-dialog.html', + styleUrl: './user-detail-dialog.scss', +}) +export class UserDetailDialog implements OnInit { + dialogRef = inject>(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + private svc = inject(FeedsService); + private sb = inject(MatSnackBar); + + detail = signal(null); + loading = signal(true); + acting = signal(false); + + get initials() { + return (this.data.firstName[0] + this.data.lastName[0]).toUpperCase(); + } + + ngOnInit() { + this.svc.getUserDetail(this.data.id).subscribe({ + next: d => { this.detail.set(d); this.loading.set(false); }, + error: () => this.loading.set(false), + }); + } + + toggleBan() { + this.acting.set(true); + this.svc.toggleBan(this.data.id).subscribe({ + next: r => { + this.data.isBanned = r.isBanned; + const d = this.detail(); + if (d) this.detail.set({ ...d, isBanned: r.isBanned }); + this.acting.set(false); + this.sb.open(r.isBanned ? 'Користувача заблоковано' : 'Користувача розблоковано', '', { duration: 3000 }); + this.dialogRef.close({ action: 'ban', isBanned: r.isBanned }); + }, + error: () => { this.acting.set(false); this.sb.open('Помилка', 'ОК', { duration: 3000 }); }, + }); + } + + confirmDelete() { + if (!confirm(`Видалити користувача ${this.data.firstName} ${this.data.lastName}? Цю дію не можна скасувати.`)) return; + this.acting.set(true); + this.svc.deleteUser(this.data.id).subscribe({ + next: () => { + this.acting.set(false); + this.sb.open('Користувача видалено', '', { duration: 3000 }); + this.dialogRef.close({ action: 'delete' }); + }, + error: () => { this.acting.set(false); this.sb.open('Помилка видалення', 'ОК', { duration: 3000 }); }, + }); + } +} diff --git a/event-aggregator-client/src/app/features/event-detail/event-detail.html b/event-aggregator-client/src/app/features/event-detail/event-detail.html index 4e411ac..e453d38 100644 --- a/event-aggregator-client/src/app/features/event-detail/event-detail.html +++ b/event-aggregator-client/src/app/features/event-detail/event-detail.html @@ -12,51 +12,94 @@ @if (event(); as e) {
- - arrow_back Стрічка - + +
+ + arrow_back До стрічки + + + +
+ + + + open_in_new Оригінал + +
+
+ + @if (e.imageUrl) { } -

{{ e.title }}

- -
+ +
@if (e.source) { - {{ e.source.name }} + {{ e.source.name }} } @if (e.category) { - {{ e.category }} + {{ e.category }} + } + @if (e.source?.language) { + {{ e.source?.language === 'uk' ? '🇺🇦 UA' : '🇬🇧 EN' }} } - +
+ + +

{{ e.title }}

+ + +
+ calendar_today {{ e.publishedDate | date:'d MMMM yyyy, HH:mm' }} + @if (e.eventDate) { + + event + Дата події: {{ e.eventDate | date:'d MMMM yyyy' }} + + } @if (e.location) { - + place {{ e.location }} } @if (e.author) { - person {{ e.author }} + + person {{ e.author }} + }
-

{{ e.description }}

+ +
-
+ + +
+ + open_in_new Відкрити оригінал - -
+
} diff --git a/event-aggregator-client/src/app/features/event-detail/event-detail.scss b/event-aggregator-client/src/app/features/event-detail/event-detail.scss index e9173e4..195443a 100644 --- a/event-aggregator-client/src/app/features/event-detail/event-detail.scss +++ b/event-aggregator-client/src/app/features/event-detail/event-detail.scss @@ -12,25 +12,83 @@ .detail-wrap { max-width: 760px; margin: 0 auto; - padding: 24px 16px; + padding: 24px 16px 40px; - .back-btn { margin-bottom: 16px; } + // ── Top navigation ── + .top-nav { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 24px; + .back-btn { flex-shrink: 0; } + + .action-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + + button, a { + display: flex; + align-items: center; + gap: 4px; + } + } + } + + // ── Hero image ── .hero-img { width: 100%; - max-height: 360px; + max-height: 380px; object-fit: cover; - border-radius: 8px; - margin-bottom: 24px; + border-radius: 10px; + margin-bottom: 20px; + display: block; } + // ── Tags row ── + .tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; + + .tag { + padding: 3px 10px; + border-radius: 12px; + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.3px; + } + + .source-tag { + background: #e8f0fe; + color: #1a73e8; + } + + .cat-tag { + background: #fce8e6; + color: #d93025; + } + + .lang-tag { + background: #e6f4ea; + color: #188038; + } + } + + // ── Title ── .title { - font-size: 1.75rem; + font-size: 1.8rem; font-weight: 700; margin: 0 0 16px; line-height: 1.3; + color: #111; } + // ── Meta row ── .meta { display: flex; flex-wrap: wrap; @@ -40,30 +98,145 @@ font-size: 0.875rem; color: #555; - .source { + span { display: flex; align-items: center; gap: 4px; } + + .event-date-badge { background: #e8f0fe; color: #1a73e8; - padding: 2px 10px; + padding: 3px 10px; border-radius: 12px; - font-weight: 500; + font-weight: 600; } - mat-icon { vertical-align: middle; font-size: 16px; } + mat-icon { font-size: 16px; width: 16px; height: 16px; } } - mat-divider { margin: 16px 0; } + mat-divider { margin: 20px 0; } - .body-text { + // ── Article body (rendered HTML from RSS) ── + .article-body { font-size: 1rem; - line-height: 1.7; - color: #333; - white-space: pre-line; + line-height: 1.8; + color: #222; + + // Paragraphs + p { + margin: 0 0 1.1em; + } + + // Headings + h1, h2, h3, h4, h5, h6 { + margin: 1.4em 0 0.5em; + font-weight: 700; + line-height: 1.3; + color: #111; + } + h1 { font-size: 1.6rem; } + h2 { font-size: 1.35rem; } + h3 { font-size: 1.15rem; } + + // Images + img { + max-width: 100%; + height: auto; + border-radius: 8px; + margin: 16px 0; + display: block; + } + + // Links + a { + color: #1a73e8; + text-decoration: none; + &:hover { text-decoration: underline; } + } + + // Blockquote + blockquote { + margin: 16px 0; + padding: 12px 16px; + border-left: 4px solid #1a73e8; + background: #f8f9ff; + border-radius: 0 6px 6px 0; + color: #444; + font-style: italic; + } + + // Lists + ul, ol { + margin: 0 0 1em; + padding-left: 1.5em; + li { margin-bottom: 0.3em; } + } + + // Inline code / code blocks + code { + font-family: 'Fira Code', monospace; + font-size: 0.9em; + background: #f1f3f4; + padding: 2px 5px; + border-radius: 4px; + } + + pre { + background: #f1f3f4; + padding: 14px 16px; + border-radius: 6px; + overflow-x: auto; + code { background: none; padding: 0; } + } + + // Horizontal rule + hr { + border: none; + border-top: 1px solid #e0e0e0; + margin: 24px 0; + } + + // Tables + table { + width: 100%; + border-collapse: collapse; + margin: 16px 0; + font-size: 0.9rem; + + th, td { + border: 1px solid #e0e0e0; + padding: 8px 12px; + text-align: left; + } + + th { + background: #f5f5f5; + font-weight: 600; + } + + tr:hover td { background: #fafafa; } + } } - .actions { + // ── Bottom action row ── + .bottom-actions { display: flex; flex-wrap: wrap; - gap: 12px; - margin-top: 32px; + gap: 10px; + margin-top: 8px; + + button, a { + display: flex; + align-items: center; + gap: 4px; + } + } + + // ── Active state colours for toggle buttons ── + .active-saved { + color: #f59e0b !important; + border-color: #f59e0b !important; + } + + .active-calendar { + color: #1a73e8 !important; + border-color: #1a73e8 !important; } } diff --git a/event-aggregator-client/src/app/features/event-detail/event-detail.ts b/event-aggregator-client/src/app/features/event-detail/event-detail.ts index 250349a..6341b31 100644 --- a/event-aggregator-client/src/app/features/event-detail/event-detail.ts +++ b/event-aggregator-client/src/app/features/event-detail/event-detail.ts @@ -1,12 +1,17 @@ -import { Component, inject, OnInit, signal } from '@angular/core'; +import { Component, computed, inject, OnInit, signal } from '@angular/core'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { DatePipe } from '@angular/common'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatChipsModule } from '@angular/material/chips'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatDividerModule } from '@angular/material/divider'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { AuthService } from '../../core/services/auth.service'; import { EventsService } from '../../core/services/events.service'; +import { UsersService } from '../../core/services/users.service'; import { EventItem } from '../../core/models/event.model'; @Component({ @@ -15,23 +20,82 @@ import { EventItem } from '../../core/models/event.model'; imports: [ DatePipe, RouterLink, MatButtonModule, MatIconModule, MatChipsModule, - MatProgressSpinnerModule, MatDividerModule, + MatProgressSpinnerModule, MatDividerModule, MatTooltipModule, ], templateUrl: './event-detail.html', styleUrl: './event-detail.scss', }) export class EventDetail implements OnInit { - private route = inject(ActivatedRoute); + private route = inject(ActivatedRoute); private eventsService = inject(EventsService); + private usersService = inject(UsersService); + private auth = inject(AuthService); + private sanitizer = inject(DomSanitizer); + private snackBar = inject(MatSnackBar); - event = signal(null); - loading = signal(true); + event = signal(null); + loading = signal(true); + saved = signal(false); + inCalendar = signal(false); + + get userId() { return this.auth.currentUser()?.id ?? 0; } + + safeContent = computed((): SafeHtml | null => { + const e = this.event(); + if (!e) return null; + const html = e.contentHtml || e.description; + return this.sanitizer.bypassSecurityTrustHtml(html); + }); ngOnInit() { const id = Number(this.route.snapshot.paramMap.get('id')); + this.eventsService.getById(id).subscribe({ - next: e => { this.event.set(e); this.loading.set(false); }, - error: () => this.loading.set(false), + next: e => { this.event.set(e); this.loading.set(false); }, + error: () => this.loading.set(false), }); + + this.usersService.getSavedEvents(this.userId) + .subscribe(evs => { if (evs.some(e => e.id === id)) this.saved.set(true); }); + + this.usersService.getCalendarEvents(this.userId) + .subscribe(evs => { if (evs.some(e => e.id === id)) this.inCalendar.set(true); }); + } + + toggleSaved() { + const ev = this.event(); + if (!ev) return; + + if (this.saved()) { + this.usersService.removeEventStatus(this.userId, ev.id).subscribe(() => { + this.saved.set(false); + this.snackBar.open('Прибрано зі збережених', '', { duration: 2000 }); + }); + } else { + this.usersService.setEventStatus(this.userId, ev.id, { status: 'Interesting', isInCalendar: false }) + .subscribe(() => { + this.saved.set(true); + this.snackBar.open('Збережено ★', '', { duration: 2000 }); + }); + } + } + + toggleCalendar() { + const ev = this.event(); + if (!ev) return; + + if (this.inCalendar()) { + this.usersService.setEventStatus(this.userId, ev.id, { isInCalendar: false }) + .subscribe(() => { + this.inCalendar.set(false); + this.snackBar.open('Прибрано з календаря', '', { duration: 2000 }); + }); + } else { + this.usersService.setEventStatus(this.userId, ev.id, { isInCalendar: true }) + .subscribe(() => { + this.inCalendar.set(true); + this.snackBar.open('Додано до календаря 📅', '', { duration: 2000 }); + }); + } } } diff --git a/event-aggregator-client/src/app/features/feed/feed.html b/event-aggregator-client/src/app/features/feed/feed.html index e42f7f7..016df4d 100644 --- a/event-aggregator-client/src/app/features/feed/feed.html +++ b/event-aggregator-client/src/app/features/feed/feed.html @@ -31,17 +31,7 @@ (click)="filterByCategory('science')">🔬 Наука - - @for (group of filterGroups(); track group.type) { -
-

{{ group.label }}

- @for (filter of group.filters; track filter.id) { - {{ filter.name }} - } -
- - } diff --git a/event-aggregator-client/src/app/features/feed/feed.ts b/event-aggregator-client/src/app/features/feed/feed.ts index 8f3bfc9..0127e13 100644 --- a/event-aggregator-client/src/app/features/feed/feed.ts +++ b/event-aggregator-client/src/app/features/feed/feed.ts @@ -2,7 +2,6 @@ import { Component, inject, OnInit, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatSidenavModule } from '@angular/material/sidenav'; import { MatListModule } from '@angular/material/list'; -import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; @@ -24,7 +23,7 @@ import { EventCard } from '../../shared/components/event-card/event-card'; standalone: true, imports: [ FormsModule, - MatSidenavModule, MatListModule, MatCheckboxModule, + MatSidenavModule, MatListModule, MatButtonModule, MatIconModule, MatInputModule, MatFormFieldModule, MatProgressSpinnerModule, MatPaginatorModule, MatDividerModule, diff --git a/event-aggregator-client/src/app/features/profile/profile.html b/event-aggregator-client/src/app/features/profile/profile.html index f4f6308..2f21475 100644 --- a/event-aggregator-client/src/app/features/profile/profile.html +++ b/event-aggregator-client/src/app/features/profile/profile.html @@ -38,6 +38,84 @@ + + + +
+ send + Telegram-сповіщення +
+ +
+ + Отримувати дайджести у Telegram + + + @if (tgEnabled()) { + + Telegram юзернейм + @  + + Введіть юзернейм без @ і натисніть «Зберегти» + + + + @if (tgChatId()) { +
+ check_circle + Бот підключено — ви будете отримувати дайджести в Telegram +
+ } @else if (tgUsername()) { +
+ schedule + + Збережіть та напишіть команду /start нашому Telegram-боту, + щоб підключити акаунт + +
+ } + } + + +
+ + + +
+ send + Надіслати дайджест зараз +
+
+ + + +
+ } @@ -91,19 +169,95 @@ tune Підписки
-

Обрані фільтри формують стрічку та email-дайджест.

- @for (group of allGroups(); track group.type) { -
-

{{ group.label }}

- - @for (filter of group.filters; track filter.id) { - - @if (isSubscribed(filter.id)) { check } - {{ filter.name }} - - } - -
+

Обрані фільтри та ключові слова формують стрічку та email-дайджест.

+ + +

Мова

+
+ + +
+

Якщо нічого не обрано — показуються всі мови.

+ + + + +

Категорія

+
+ @for (cat of CATEGORIES; track cat.value) { + + } +
+

Якщо нічого не обрано — показуються всі категорії.

+ + + + +

Надіслати дайджест зараз

+
+ + +
+ + + + + +

Власні ключові слова

+

Додайте будь-які слова — система шукатиме їх у заголовках і описах подій.

+ +
+ + Нове ключове слово + + Натисніть Enter або кнопку «+» + + +
+ + @if (userKeywords().length > 0) { + + @for (kw of userKeywords(); track kw.id) { + + {{ kw.keyword }} + + + } + + } @else { +

Ще немає ключових слів

}
@@ -175,7 +329,7 @@

} @else { -

event_note Усі заплановані події

+

event_note Усі відмічені матеріали

} @if (eventsForDate().length === 0) { diff --git a/event-aggregator-client/src/app/features/profile/profile.scss b/event-aggregator-client/src/app/features/profile/profile.scss index 01b3819..bd5572e 100644 --- a/event-aggregator-client/src/app/features/profile/profile.scss +++ b/event-aggregator-client/src/app/features/profile/profile.scss @@ -74,6 +74,46 @@ input { letter-spacing: 0.25em; font-size: 1.1rem; text-align: center; } } +// ── Мова/Категорія кнопки ───────────────────────────────────────────────── + +.group-label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #888; + margin: 0 0 10px; +} + +.lang-buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; + + button { + border-radius: 20px; + min-width: 100px; + transition: background 0.15s, color 0.15s; + + &.active { + background-color: #e8f0fe; + color: #1a73e8; + border-color: #1a73e8; + font-weight: 500; + } + } +} + +.send-now-block { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 4px; +} + +.hint.muted { color: #bbb; } + // ── Підписки ────────────────────────────────────────────────────────────── .filter-group { @@ -232,6 +272,76 @@ } } +// ── Telegram-секція ─────────────────────────────────────────────────────── + +.section-divider { margin: 28px 0 20px; } + +.section-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 1rem; + font-weight: 600; + color: #333; + margin-bottom: 16px; + + .tg-icon { color: #229ed9; } +} + +.telegram-block { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 480px; + + .tg-field { + width: 100%; + } + + .tg-status { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 14px; + border-radius: 8px; + font-size: 0.875rem; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + flex-shrink: 0; + margin-top: 1px; + } + + code { + background: rgba(0,0,0,0.08); + padding: 1px 5px; + border-radius: 4px; + font-family: monospace; + } + + &.connected { + background: #e6f4ea; + color: #1e7e34; + mat-icon { color: #34a853; } + } + + &.pending { + background: #fff8e1; + color: #795548; + mat-icon { color: #f9a825; } + } + } + + .tg-save-btn { + align-self: flex-start; + display: flex; + align-items: center; + gap: 6px; + } +} + // Виділення дат у календарі з подіями ::ng-deep .has-event .mat-calendar-body-cell-content { background-color: #e8f0fe !important; diff --git a/event-aggregator-client/src/app/features/profile/profile.ts b/event-aggregator-client/src/app/features/profile/profile.ts index c40b954..d3a6cac 100644 --- a/event-aggregator-client/src/app/features/profile/profile.ts +++ b/event-aggregator-client/src/app/features/profile/profile.ts @@ -9,15 +9,19 @@ import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatChipsModule } from '@angular/material/chips'; import { MatDividerModule } from '@angular/material/divider'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTabsModule } from '@angular/material/tabs'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { MatDatepickerModule, MatCalendarCellClassFunction } from '@angular/material/datepicker'; import { MatNativeDateModule } from '@angular/material/core'; import { QRCodeComponent } from 'angularx-qrcode'; import { AuthService } from '../../core/services/auth.service'; import { UsersService } from '../../core/services/users.service'; import { FiltersService } from '../../core/services/filters.service'; +import { DigestService } from '../../core/services/digest.service'; +import { KeywordsService, UserKeyword } from '../../core/services/keywords.service'; import { Filter, FilterGroup } from '../../core/models/filter.model'; import { EventItem } from '../../core/models/event.model'; import { User } from '../../core/models/user.model'; @@ -29,8 +33,8 @@ import { User } from '../../core/models/user.model'; FormsModule, DatePipe, RouterLink, QRCodeComponent, MatButtonModule, MatIconModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatChipsModule, - MatDividerModule, MatProgressSpinnerModule, MatTabsModule, - MatDatepickerModule, MatNativeDateModule, + MatDividerModule, MatSlideToggleModule, MatProgressSpinnerModule, MatTabsModule, + MatDatepickerModule, MatNativeDateModule, MatTooltipModule, ], templateUrl: './profile.html', styleUrl: './profile.scss', @@ -39,6 +43,8 @@ export class Profile implements OnInit { private auth = inject(AuthService); private usersService = inject(UsersService); private filtersService = inject(FiltersService); + private digestService = inject(DigestService); + private keywordsService = inject(KeywordsService); private snackBar = inject(MatSnackBar); user = signal(null); @@ -48,6 +54,31 @@ export class Profile implements OnInit { calendarEvents = signal([]); loading = signal(true); + // Мова / категорії + preferredLanguage = signal(''); + preferredCategories = signal>(new Set()); + + readonly CATEGORIES = [ + { value: 'events', label: '📅 Події' }, + { value: 'news', label: '📰 Новини' }, + { value: 'tech', label: '💻 IT/Tech' }, + { value: 'science', label: '🔬 Наука' }, + ]; + + // Ключові слова + userKeywords = signal([]); + newKeyword = ''; + + // Send now + sendingEmail = signal(false); + sendingTelegram = signal(false); + + // Telegram + tgEnabled = signal(false); + tgUsername = signal(''); + tgChatId = signal(null); + tgSaving = signal(false); + // 2FA twoFactorEnabled = signal(false); qrUri = signal(''); @@ -86,6 +117,13 @@ export class Profile implements OnInit { next: u => { this.user.set(u); this.userFilterIds.set(new Set(u.filters.map(f => f.id))); + this.tgEnabled.set(u.telegramNotificationsEnabled); + this.tgUsername.set(u.telegramUsername ?? ''); + this.tgChatId.set(u.telegramChatId ?? null); + this.preferredLanguage.set(u.preferredLanguage ?? ''); + this.preferredCategories.set( + new Set((u.preferredCategories ?? '').split(',').filter(Boolean)) + ); this.loading.set(false); }, error: () => this.loading.set(false), @@ -94,6 +132,7 @@ export class Profile implements OnInit { this.twoFactorEnabled.set(this.auth.currentUser()?.twoFactorEnabled ?? false); this.usersService.getSavedEvents(this.userId).subscribe(e => this.savedEvents.set(e)); this.usersService.getCalendarEvents(this.userId).subscribe(e => this.calendarEvents.set(e)); + this.keywordsService.getAll(this.userId).subscribe(k => this.userKeywords.set(k)); } isSubscribed(filterId: number) { return this.userFilterIds().has(filterId); } @@ -113,6 +152,28 @@ export class Profile implements OnInit { } } + toggleCategory(cat: string) { + this.preferredCategories.update(s => { + const n = new Set(s); + n.has(cat) ? n.delete(cat) : n.add(cat); + return n; + }); + this.savePreferences(); + } + + setLanguage(lang: string) { + this.preferredLanguage.set(this.preferredLanguage() === lang ? '' : lang); + this.savePreferences(); + } + + savePreferences() { + const cats = [...this.preferredCategories()].join(','); + this.usersService.update(this.userId, { + preferredLanguage: this.preferredLanguage() || undefined, + preferredCategories: cats || undefined, + }).subscribe(); + } + saveProfile() { const u = this.user(); if (!u) return; @@ -123,6 +184,24 @@ export class Profile implements OnInit { }).subscribe(() => this.snackBar.open('Профіль збережено', '', { duration: 2000 })); } + saveTelegram() { + this.tgSaving.set(true); + this.usersService.updateTelegram(this.userId, { + telegramNotificationsEnabled: this.tgEnabled(), + telegramUsername: this.tgUsername().trim().replace(/^@/, '') || undefined, + }).subscribe({ + next: res => { + this.tgChatId.set(res.telegramChatId ?? null); + this.tgSaving.set(false); + this.snackBar.open('Telegram-налаштування збережено', '', { duration: 2500 }); + }, + error: () => { + this.tgSaving.set(false); + this.snackBar.open('Помилка збереження', 'ОК', { duration: 3000 }); + }, + }); + } + onDateSelected(date: Date | null) { const cur = this.selectedDate(); if (cur && date && cur.toDateString() === date.toDateString()) { @@ -132,13 +211,77 @@ export class Profile implements OnInit { } } + // ── Ключові слова ──────────────────────────────────────────────────────── + + addKeyword() { + const kw = this.newKeyword.trim(); + if (!kw) return; + this.keywordsService.add(this.userId, kw).subscribe({ + next: k => { + this.userKeywords.update(list => [...list, k]); + this.newKeyword = ''; + this.snackBar.open(`«${k.keyword}» додано`, '', { duration: 2000 }); + }, + error: err => this.snackBar.open(err.error?.error ?? 'Помилка', 'ОК', { duration: 3000 }), + }); + } + + removeKeyword(kw: UserKeyword) { + this.keywordsService.delete(this.userId, kw.id).subscribe({ + next: () => { + this.userKeywords.update(list => list.filter(k => k.id !== kw.id)); + this.snackBar.open(`«${kw.keyword}» видалено`, '', { duration: 2000 }); + }, + }); + } + + onKeywordEnter(event: KeyboardEvent) { + if (event.key === 'Enter') this.addKeyword(); + } + + // ── Send now ───────────────────────────────────────────────────────────── + + sendEmailNow() { + this.sendingEmail.set(true); + this.digestService.sendNow(this.userId).subscribe({ + next: res => { + this.sendingEmail.set(false); + this.snackBar.open(res.message, '', { duration: 3000 }); + }, + error: err => { + this.sendingEmail.set(false); + this.snackBar.open(err.error?.error ?? 'Помилка відправки', 'ОК', { duration: 4000 }); + }, + }); + } + + sendTelegramNow() { + this.sendingTelegram.set(true); + this.digestService.sendTelegramNow(this.userId).subscribe({ + next: res => { + this.sendingTelegram.set(false); + this.snackBar.open(res.message, '', { duration: 3000 }); + }, + error: err => { + this.sendingTelegram.set(false); + this.snackBar.open(err.error?.error ?? 'Помилка відправки', 'ОК', { duration: 4000 }); + }, + }); + } + // ── 2FA ────────────────────────────────────────────────────────────────── setupTwoFactor() { this.twoFaLoading.set(true); this.auth.setupTwoFactor().subscribe({ next: res => { this.qrUri.set(res.otpauthUri); this.twoFaLoading.set(false); }, - error: () => this.twoFaLoading.set(false), + error: err => { + this.twoFaLoading.set(false); + const msg = err.status === 401 + ? 'Сесія закінчилась — увійдіть знову' + : (err.error?.message ?? 'Помилка налаштування 2FA'); + this.snackBar.open(msg, 'ОК', { duration: 4000 }); + }, }); } diff --git a/event-aggregator-client/src/environments/environment.production.ts b/event-aggregator-client/src/environments/environment.production.ts new file mode 100644 index 0000000..afab2e1 --- /dev/null +++ b/event-aggregator-client/src/environments/environment.production.ts @@ -0,0 +1,4 @@ +export const environment = { + production: true, + apiUrl: '/api', +}; diff --git a/event-aggregator-client/src/environments/environment.ts b/event-aggregator-client/src/environments/environment.ts index 38f7b38..c19b176 100644 --- a/event-aggregator-client/src/environments/environment.ts +++ b/event-aggregator-client/src/environments/environment.ts @@ -1,4 +1,4 @@ export const environment = { production: false, - apiUrl: 'http://localhost:5269/api', + apiUrl: 'https://localhost:7238/api', }; diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..8e912b4 --- /dev/null +++ b/start.bat @@ -0,0 +1,21 @@ +@echo off +title EventAggregator +echo. +echo ============================== +echo EventAggregator - Starting... +echo ============================== +echo. + +start "Backend (.NET)" cmd /k "title Backend && cd /d %~dp0EventAggregator && dotnet run" + +echo Waiting for backend to initialize... +timeout /t 5 /nobreak > nul + +start "Frontend (Angular)" cmd /k "title Angular && cd /d %~dp0event-aggregator-client && npm start" + +echo. +echo Backend: https://localhost:7238 +echo Frontend: http://localhost:4200 ^<-- open this in browser +echo. +echo Close both terminal windows to stop. +pause