diff --git a/Aberwyn/Aberwyn.csproj b/Aberwyn/Aberwyn.csproj index 33d4e20..b681382 100644 --- a/Aberwyn/Aberwyn.csproj +++ b/Aberwyn/Aberwyn.csproj @@ -27,6 +27,7 @@ + @@ -51,6 +52,7 @@ + diff --git a/Aberwyn/Controllers/FoodMenuController.cs b/Aberwyn/Controllers/FoodMenuController.cs index 1682a63..c405fd5 100644 --- a/Aberwyn/Controllers/FoodMenuController.cs +++ b/Aberwyn/Controllers/FoodMenuController.cs @@ -212,7 +212,6 @@ namespace Aberwyn.Controllers return RedirectToAction("Veckomeny", new { week, year }); } - - } + } diff --git a/Aberwyn/Controllers/PushController.cs b/Aberwyn/Controllers/PushController.cs new file mode 100644 index 0000000..3c1ef73 --- /dev/null +++ b/Aberwyn/Controllers/PushController.cs @@ -0,0 +1,87 @@ +using Aberwyn.Data; +using Aberwyn.Models; +using Lib.Net.Http.WebPush; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Aberwyn.Controllers +{ + [ApiController] + [Route("api/push")] + public class PushController : ControllerBase + { + private readonly ApplicationDbContext _context; + private readonly PushNotificationService _notificationService; + + public PushController(ApplicationDbContext context, PushNotificationService notificationService) + { + _context = context; + _notificationService = notificationService; + } + + + [HttpPost("subscribe")] + public async Task Subscribe([FromBody] PushSubscription subscription) + { + var existing = await _context.PushSubscribers + .FirstOrDefaultAsync(s => s.Endpoint == subscription.Endpoint); + + if (existing == null) + { + var newSubscriber = new PushSubscriber + { + Endpoint = subscription.Endpoint, + P256DH = subscription.Keys["p256dh"], + Auth = subscription.Keys["auth"] + }; + + _context.PushSubscribers.Add(newSubscriber); + } + else + { + existing.P256DH = subscription.Keys["p256dh"]; + existing.Auth = subscription.Keys["auth"]; + } + await _context.SaveChangesAsync(); + + return Ok(); + } + + [HttpGet("vapid-public-key")] + public IActionResult GetVapidKey([FromServices] IConfiguration config) + { + var publicKey = config["VapidKeys:PublicKey"]; + return Content(publicKey); + } + + [HttpPost("notify-all")] + public async Task NotifyAll([FromBody] PushMessageDto message) + { + if (string.IsNullOrWhiteSpace(message.Title) || string.IsNullOrWhiteSpace(message.Body)) + { + return BadRequest("Titel och meddelande krävs."); + } + + var subscribers = await _context.PushSubscribers.ToListAsync(); + var payload = $"{{\"title\":\"{message.Title}\",\"body\":\"{message.Body}\"}}"; + + int successCount = 0; + foreach (var sub in subscribers) + { + try + { + _notificationService.SendNotification(sub.Endpoint, sub.P256DH, sub.Auth, payload); + successCount++; + } + catch (Exception ex) + { + Console.WriteLine($"❌ Misslyckades att skicka till {sub.Endpoint}: {ex.Message}"); + } + } + + return Ok($"Skickade notiser till {successCount} användare."); + } + + } + +} diff --git a/Aberwyn/Data/ApplicationDbContext.cs b/Aberwyn/Data/ApplicationDbContext.cs index 96a2609..3d1558a 100644 --- a/Aberwyn/Data/ApplicationDbContext.cs +++ b/Aberwyn/Data/ApplicationDbContext.cs @@ -14,5 +14,6 @@ namespace Aberwyn.Data public DbSet BudgetPeriods { get; set; } public DbSet BudgetCategories { get; set; } public DbSet BudgetItems { get; set; } + public DbSet PushSubscribers { get; set; } } } diff --git a/Aberwyn/Data/BudgetContext.cs b/Aberwyn/Data/BudgetContext.cs deleted file mode 100644 index 8ab1ac1..0000000 --- a/Aberwyn/Data/BudgetContext.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Aberwyn.Models; // Adjust this namespace as needed - -namespace Aberwyn.Data -{ - public class BudgetContext : DbContext - { - public BudgetContext(DbContextOptions options) : base(options) { } - - public DbSet BudgetItems { get; set; } // This line maps your BudgetItem model - } -} \ No newline at end of file diff --git a/Aberwyn/Data/PushNotificationService.cs b/Aberwyn/Data/PushNotificationService.cs new file mode 100644 index 0000000..64a1b3a --- /dev/null +++ b/Aberwyn/Data/PushNotificationService.cs @@ -0,0 +1,31 @@ +using WebPush; +using System; + +namespace Aberwyn.Data +{ + public class PushNotificationService + { + private readonly VapidDetails _vapidDetails; + private readonly WebPushClient _client; + + public PushNotificationService(string subject, string publicKey, string privateKey) + { + _vapidDetails = new VapidDetails(subject, publicKey, privateKey); + _client = new WebPushClient(); + } + + public void SendNotification(string endpoint, string p256dh, string auth, string payload) + { + var subscription = new PushSubscription(endpoint, p256dh, auth); + + try + { + _client.SendNotification(subscription, payload, _vapidDetails); + } + catch (WebPushException ex) + { + Console.WriteLine($"❌ Push-fel: {ex.Message}"); + } + } + } +} diff --git a/Aberwyn/Migrations/20250523075931_AddPushSubscribers.Designer.cs b/Aberwyn/Migrations/20250523075931_AddPushSubscribers.Designer.cs new file mode 100644 index 0000000..ced95c5 --- /dev/null +++ b/Aberwyn/Migrations/20250523075931_AddPushSubscribers.Designer.cs @@ -0,0 +1,403 @@ +// +using System; +using Aberwyn.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Aberwyn.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20250523075931_AddPushSubscribers")] + partial class AddPushSubscribers + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.36") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Aberwyn.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("varchar(255)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime(6)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + b.Property("PhoneNumber") + .HasColumnType("longtext"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("SecurityStamp") + .HasColumnType("longtext"); + + b.Property("TwoFactorEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Aberwyn.Models.BudgetCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BudgetPeriodId") + .HasColumnType("int"); + + b.Property("Color") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Order") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BudgetPeriodId"); + + b.ToTable("BudgetCategories"); + }); + + modelBuilder.Entity("Aberwyn.Models.BudgetItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("BudgetCategoryId") + .HasColumnType("int"); + + b.Property("IncludeInSummary") + .HasColumnType("tinyint(1)"); + + b.Property("IsExpense") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Order") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BudgetCategoryId"); + + b.ToTable("BudgetItems"); + }); + + modelBuilder.Entity("Aberwyn.Models.BudgetPeriod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Month") + .HasColumnType("int"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("Year") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("BudgetPeriods"); + }); + + modelBuilder.Entity("Aberwyn.Models.PushSubscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("P256DH") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("PushSubscribers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("varchar(255)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("ProviderKey") + .HasColumnType("varchar(255)"); + + b.Property("ProviderDisplayName") + .HasColumnType("longtext"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("varchar(255)"); + + b.Property("RoleId") + .HasColumnType("varchar(255)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("varchar(255)"); + + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("Name") + .HasColumnType("varchar(255)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Aberwyn.Models.BudgetCategory", b => + { + b.HasOne("Aberwyn.Models.BudgetPeriod", "BudgetPeriod") + .WithMany("Categories") + .HasForeignKey("BudgetPeriodId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BudgetPeriod"); + }); + + modelBuilder.Entity("Aberwyn.Models.BudgetItem", b => + { + b.HasOne("Aberwyn.Models.BudgetCategory", "BudgetCategory") + .WithMany("Items") + .HasForeignKey("BudgetCategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BudgetCategory"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Aberwyn.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Aberwyn.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Aberwyn.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Aberwyn.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Aberwyn.Models.BudgetCategory", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Aberwyn.Models.BudgetPeriod", b => + { + b.Navigation("Categories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Aberwyn/Migrations/20250523075931_AddPushSubscribers.cs b/Aberwyn/Migrations/20250523075931_AddPushSubscribers.cs new file mode 100644 index 0000000..8e2455c --- /dev/null +++ b/Aberwyn/Migrations/20250523075931_AddPushSubscribers.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Aberwyn.Migrations +{ + public partial class AddPushSubscribers : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PushSubscribers", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Endpoint = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + P256DH = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Auth = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_PushSubscribers", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PushSubscribers"); + } + } +} diff --git a/Aberwyn/Migrations/ApplicationDbContextModelSnapshot.cs b/Aberwyn/Migrations/ApplicationDbContextModelSnapshot.cs index e9f964c..d58148b 100644 --- a/Aberwyn/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Aberwyn/Migrations/ApplicationDbContextModelSnapshot.cs @@ -162,6 +162,29 @@ namespace Aberwyn.Migrations b.ToTable("BudgetPeriods"); }); + modelBuilder.Entity("Aberwyn.Models.PushSubscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("P256DH") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("PushSubscribers"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { b.Property("Id") diff --git a/Aberwyn/Models/PushSubscriber.cs b/Aberwyn/Models/PushSubscriber.cs new file mode 100644 index 0000000..f8b2a46 --- /dev/null +++ b/Aberwyn/Models/PushSubscriber.cs @@ -0,0 +1,16 @@ +namespace Aberwyn.Models +{ + public class PushSubscriber + { + public int Id { get; set; } + public string Endpoint { get; set; } + public string P256DH { get; set; } + public string Auth { get; set; } + } + public class PushMessageDto + { + public string Title { get; set; } + public string Body { get; set; } + } + +} diff --git a/Aberwyn/Models/VapidOptions.cs b/Aberwyn/Models/VapidOptions.cs new file mode 100644 index 0000000..24e59f5 --- /dev/null +++ b/Aberwyn/Models/VapidOptions.cs @@ -0,0 +1,9 @@ +namespace Aberwyn.Models +{ + public class VapidOptions + { + public string Subject { get; set; } + public string PublicKey { get; set; } + public string PrivateKey { get; set; } + } +} diff --git a/Aberwyn/Program.cs b/Aberwyn/Program.cs index 2788ef6..0ce3cc7 100644 --- a/Aberwyn/Program.cs +++ b/Aberwyn/Program.cs @@ -53,6 +53,17 @@ builder.Services.AddControllersWithViews(); // Register your services builder.Services.AddScoped(); +builder.Services.AddSingleton(sp => +{ + var config = sp.GetRequiredService(); + return new PushNotificationService( + config["VapidKeys:Subject"], + config["VapidKeys:PublicKey"], + config["VapidKeys:PrivateKey"] + ); +}); + +builder.Services.Configure(builder.Configuration.GetSection("Vapid")); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); @@ -67,6 +78,15 @@ builder.Configuration .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) .AddEnvironmentVariables(); +builder.Services.AddSingleton(sp => +{ + var config = sp.GetRequiredService(); + return new PushNotificationService( + config["VapidKeys:Subject"], + config["VapidKeys:PublicKey"], + config["VapidKeys:PrivateKey"] + ); +}); var app = builder.Build(); @@ -105,4 +125,7 @@ using (var scope = app.Services.CreateScope()) var context = services.GetRequiredService(); await TestDataSeeder.SeedBudget(context); } + + + app.Run(); diff --git a/Aberwyn/Views/Admin/Index.cshtml b/Aberwyn/Views/Admin/Index.cshtml index 317396c..a89423f 100644 --- a/Aberwyn/Views/Admin/Index.cshtml +++ b/Aberwyn/Views/Admin/Index.cshtml @@ -70,3 +70,37 @@ } + +

Testa Pushnotis

+
+
+ + +
+
+ + +
+ +
+ + diff --git a/Aberwyn/Views/Meal/View.cshtml b/Aberwyn/Views/Meal/View.cshtml index c20438e..5f1c96e 100644 --- a/Aberwyn/Views/Meal/View.cshtml +++ b/Aberwyn/Views/Meal/View.cshtml @@ -223,6 +223,36 @@ }); } }); + if ('serviceWorker' in navigator && 'PushManager' in window) { + navigator.serviceWorker.register('/service-worker.js') + .then(function (registration) { + console.log('Service Worker registered', registration); + return registration.pushManager.getSubscription() + .then(async function (subscription) { + if (subscription) { + console.log('Already subscribed to push notifications.'); + return subscription; + } + const response = await fetch('/api/push/vapid-public-key'); + const vapidPublicKey = await response.text(); + const convertedVapidKey = urlBase64ToUint8Array(vapidPublicKey); + + return registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: convertedVapidKey + }); + }); + }).then(function (subscription) { + return fetch('/api/push/subscribe', { + method: 'POST', + body: JSON.stringify(subscription), + headers: { + 'Content-Type': 'application/json' + } + }); + }); + } +