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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
**/bin/
**/obj/
**/node_modules/
**/.angular/
**/dist/
EventAggregator/events.db
EventAggregator/appsettings.json
.git/
.vscode/
30 changes: 30 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}
48 changes: 48 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}

]
}
28 changes: 28 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
95 changes: 86 additions & 9 deletions EventAggregator/Controllers/AdminController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,34 +7,111 @@ 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<IActionResult> 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 });
}

/// <summary>Загальна статистика + дані по кожному джерелу</summary>
[HttpGet("stats")]
public async Task<IActionResult> 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)
.ToListAsync();

return Ok(new { totalEvents, totalUsers, activeSources, sources });
}

// ── Users ─────────────────────────────────────────────────────────────────

[HttpGet("users")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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();
}
}
3 changes: 3 additions & 0 deletions EventAggregator/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ public async Task<IActionResult> 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 });

Expand Down
38 changes: 35 additions & 3 deletions EventAggregator/Controllers/DigestController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public async Task<IActionResult> SendAll()
return Ok(new { message = "Дайджести оброблено" });
}

/// <summary>Примусово надіслати дайджест конкретному користувачу (для тестування)</summary>
/// <summary>Надіслати дайджест на пошту (кнопка «Надіслати зараз»)</summary>
[HttpPost("send/{userId:int}")]
public async Task<IActionResult> SendToUser(int userId)
{
Expand All @@ -41,8 +41,40 @@ public async Task<IActionResult> 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 });
}
}

/// <summary>Надіслати дайджест тільки в Telegram (кнопка «У Telegram»)</summary>
[HttpPost("send-telegram/{userId:int}")]
public async Task<IActionResult> 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)
{
Expand Down
2 changes: 1 addition & 1 deletion EventAggregator/Controllers/FeedsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,6 @@ public async Task<IActionResult> 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 });
}
}
58 changes: 58 additions & 0 deletions EventAggregator/Controllers/KeywordsController.cs
Original file line number Diff line number Diff line change
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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);
Loading