diff --git a/API/Controller/Admin/DTOs/AddWebhookDto.cs b/API/Controller/Admin/DTOs/AddWebhookDto.cs
new file mode 100644
index 00000000..6e84991e
--- /dev/null
+++ b/API/Controller/Admin/DTOs/AddWebhookDto.cs
@@ -0,0 +1,7 @@
+namespace OpenShock.API.Controller.Admin.DTOs;
+
+public sealed class AddWebhookDto
+{
+ public required string Name { get; set; }
+ public required Uri Url { get; set; }
+}
\ No newline at end of file
diff --git a/API/Controller/Admin/WebhookAdd.cs b/API/Controller/Admin/WebhookAdd.cs
new file mode 100644
index 00000000..dbf0cbf7
--- /dev/null
+++ b/API/Controller/Admin/WebhookAdd.cs
@@ -0,0 +1,23 @@
+using Microsoft.AspNetCore.Mvc;
+using OpenShock.API.Controller.Admin.DTOs;
+using OpenShock.Common.Services.Webhook;
+
+namespace OpenShock.API.Controller.Admin;
+
+public sealed partial class AdminController
+{
+ ///
+ /// Creates a webhook
+ ///
+ /// OK
+ /// Unauthorized
+ [HttpPost("webhooks")]
+ public async Task AddWebhook([FromBody] AddWebhookDto body, [FromServices] IWebhookService webhookService)
+ {
+ var result = await webhookService.AddWebhook(body.Name, body.Url);
+ return result.Match(
+ success => Ok(success.Value),
+ unsupported => BadRequest("Only discord webhooks are currently supported!")
+ );
+ }
+}
\ No newline at end of file
diff --git a/API/Controller/Admin/WebhookList.cs b/API/Controller/Admin/WebhookList.cs
new file mode 100644
index 00000000..68130b9f
--- /dev/null
+++ b/API/Controller/Admin/WebhookList.cs
@@ -0,0 +1,20 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.Common.Models;
+using OpenShock.Common.Services.Webhook;
+
+namespace OpenShock.API.Controller.Admin;
+
+public sealed partial class AdminController
+{
+ ///
+ /// List webhooks
+ ///
+ /// OK
+ /// Unauthorized
+ [HttpGet("webhooks")]
+ public async Task ListWebhooks([FromServices] IWebhookService webhookService)
+ {
+ return await webhookService.GetWebhooks();
+ }
+}
\ No newline at end of file
diff --git a/API/Controller/Admin/WebhookRemove.cs b/API/Controller/Admin/WebhookRemove.cs
new file mode 100644
index 00000000..86b4d3c7
--- /dev/null
+++ b/API/Controller/Admin/WebhookRemove.cs
@@ -0,0 +1,22 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OpenShock.Common.Services.Webhook;
+
+namespace OpenShock.API.Controller.Admin;
+
+public sealed partial class AdminController
+{
+ ///
+ /// Removes a webhook
+ ///
+ /// OK
+ /// Unauthorized
+ [HttpDelete("webhooks/{id}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task RemoveWebhook([FromRoute] Guid id, [FromServices] IWebhookService webhookService)
+ {
+ bool removed = await webhookService.RemoveWebhook(id);
+ return removed ? Ok() : NotFound();
+ }
+}
\ No newline at end of file
diff --git a/Common/Migrations/20250525165800_AddWebhooksTable.Designer.cs b/Common/Migrations/20250525165800_AddWebhooksTable.Designer.cs
new file mode 100644
index 00000000..154e03d9
--- /dev/null
+++ b/Common/Migrations/20250525165800_AddWebhooksTable.Designer.cs
@@ -0,0 +1,1226 @@
+//
+using System;
+using System.Collections.Generic;
+using System.Net;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using OpenShock.Common.Models;
+using OpenShock.Common.OpenShockDb;
+
+#nullable disable
+
+namespace OpenShock.Common.Migrations
+{
+ [DbContext(typeof(MigrationOpenShockContext))]
+ [Migration("20250525165800_AddWebhooksTable")]
+ partial class AddWebhooksTable
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False")
+ .HasAnnotation("ProductVersion", "9.0.5")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "pbkdf2", "bcrypt_enhanced" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "caiXianlin", "petTrainer", "petrainer998DR" });
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b =>
+ {
+ b.Property("ActivatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("activated_at");
+
+ b.Property("ApiTokenCount")
+ .HasColumnType("integer")
+ .HasColumnName("api_token_count");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("DeactivatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deactivated_at");
+
+ b.Property("DeactivatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("deactivated_by_user_id");
+
+ b.Property("DeviceCount")
+ .HasColumnType("integer")
+ .HasColumnName("device_count");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("character varying")
+ .HasColumnName("email");
+
+ b.Property("EmailChangeRequestCount")
+ .HasColumnType("integer")
+ .HasColumnName("email_change_request_count");
+
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("character varying")
+ .HasColumnName("name");
+
+ b.Property("NameChangeRequestCount")
+ .HasColumnType("integer")
+ .HasColumnName("name_change_request_count");
+
+ b.Property("PasswordHashType")
+ .IsRequired()
+ .HasColumnType("character varying")
+ .HasColumnName("password_hash_type");
+
+ b.Property("PasswordResetCount")
+ .HasColumnType("integer")
+ .HasColumnName("password_reset_count");
+
+ b.Property("Roles")
+ .IsRequired()
+ .HasColumnType("role_type[]")
+ .HasColumnName("roles");
+
+ b.Property("ShockerControlLogCount")
+ .HasColumnType("integer")
+ .HasColumnName("shocker_control_log_count");
+
+ b.Property("ShockerCount")
+ .HasColumnType("integer")
+ .HasColumnName("shocker_count");
+
+ b.Property("ShockerPublicShareCount")
+ .HasColumnType("integer")
+ .HasColumnName("shocker_public_share_count");
+
+ b.Property("ShockerUserShareCount")
+ .HasColumnType("integer")
+ .HasColumnName("shocker_user_share_count");
+
+ b.ToTable((string)null);
+
+ b.ToView("admin_users_view", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("CreatedByIp")
+ .IsRequired()
+ .HasColumnType("inet")
+ .HasColumnName("created_by_ip");
+
+ b.Property("LastUsed")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_used")
+ .HasDefaultValueSql("'-infinity'::timestamp without time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("name");
+
+ b.PrimitiveCollection>("Permissions")
+ .IsRequired()
+ .HasColumnType("permission_type[]")
+ .HasColumnName("permissions");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("token_hash");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("ValidUntil")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("valid_until");
+
+ b.HasKey("Id")
+ .HasName("api_tokens_pkey");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("ValidUntil");
+
+ b.ToTable("api_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("name");
+
+ b.Property("OwnerId")
+ .HasColumnType("uuid")
+ .HasColumnName("owner_id");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("token");
+
+ b.HasKey("Id")
+ .HasName("devices_pkey");
+
+ b.HasIndex("OwnerId");
+
+ b.HasIndex("Token")
+ .IsUnique();
+
+ b.ToTable("devices", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b =>
+ {
+ b.Property("DeviceId")
+ .HasColumnType("uuid")
+ .HasColumnName("device_id");
+
+ b.Property("UpdateId")
+ .HasColumnType("integer")
+ .HasColumnName("update_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("Message")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("message");
+
+ b.Property("Status")
+ .HasColumnType("ota_update_status")
+ .HasColumnName("status");
+
+ b.Property("Version")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("version");
+
+ b.HasKey("DeviceId", "UpdateId")
+ .HasName("device_ota_updates_pkey");
+
+ b.HasIndex(new[] { "CreatedAt" }, "device_ota_updates_created_at_idx");
+
+ b.ToTable("device_ota_updates", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b =>
+ {
+ b.Property("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("WebhookId")
+ .HasColumnType("bigint")
+ .HasColumnName("webhook_id");
+
+ b.Property("WebhookToken")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("webhook_token");
+
+ b.HasKey("Name")
+ .HasName("discord_webhooks_pkey");
+
+ b.ToTable("discord_webhooks", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("name");
+
+ b.Property("OwnerId")
+ .HasColumnType("uuid")
+ .HasColumnName("owner_id");
+
+ b.HasKey("Id")
+ .HasName("public_shares_pkey");
+
+ b.HasIndex("OwnerId");
+
+ b.ToTable("public_shares", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b =>
+ {
+ b.Property("PublicShareId")
+ .HasColumnType("uuid")
+ .HasColumnName("public_share_id");
+
+ b.Property("ShockerId")
+ .HasColumnType("uuid")
+ .HasColumnName("shocker_id");
+
+ b.Property("AllowLiveControl")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("allow_livecontrol");
+
+ b.Property("AllowShock")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_shock");
+
+ b.Property("AllowSound")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_sound");
+
+ b.Property("AllowVibrate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_vibrate");
+
+ b.Property("Cooldown")
+ .HasColumnType("integer")
+ .HasColumnName("cooldown");
+
+ b.Property("IsPaused")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("is_paused");
+
+ b.Property("MaxDuration")
+ .HasColumnType("integer")
+ .HasColumnName("max_duration");
+
+ b.Property("MaxIntensity")
+ .HasColumnType("smallint")
+ .HasColumnName("max_intensity");
+
+ b.HasKey("PublicShareId", "ShockerId")
+ .HasName("public_share_shockers_pkey");
+
+ b.HasIndex("ShockerId");
+
+ b.ToTable("public_share_shockers", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("DeviceId")
+ .HasColumnType("uuid")
+ .HasColumnName("device_id");
+
+ b.Property("IsPaused")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("is_paused");
+
+ b.Property("Model")
+ .HasColumnType("shocker_model_type")
+ .HasColumnName("model");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("name");
+
+ b.Property("RfId")
+ .HasColumnType("integer")
+ .HasColumnName("rf_id");
+
+ b.HasKey("Id")
+ .HasName("shockers_pkey");
+
+ b.HasIndex("DeviceId");
+
+ b.ToTable("shockers", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ControlledByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("controlled_by_user_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("CustomName")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("custom_name");
+
+ b.Property("Duration")
+ .HasColumnType("bigint")
+ .HasColumnName("duration");
+
+ b.Property("Intensity")
+ .HasColumnType("smallint")
+ .HasColumnName("intensity");
+
+ b.Property("LiveControl")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("live_control");
+
+ b.Property("ShockerId")
+ .HasColumnType("uuid")
+ .HasColumnName("shocker_id");
+
+ b.Property("Type")
+ .HasColumnType("control_type")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("shocker_control_logs_pkey");
+
+ b.HasIndex("ControlledByUserId");
+
+ b.HasIndex("ShockerId");
+
+ b.ToTable("shocker_control_logs", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AllowLiveControl")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_livecontrol");
+
+ b.Property("AllowShock")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_shock");
+
+ b.Property("AllowSound")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_sound");
+
+ b.Property("AllowVibrate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_vibrate");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("IsPaused")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("is_paused");
+
+ b.Property("MaxDuration")
+ .HasColumnType("integer")
+ .HasColumnName("max_duration");
+
+ b.Property("MaxIntensity")
+ .HasColumnType("smallint")
+ .HasColumnName("max_intensity");
+
+ b.Property("ShockerId")
+ .HasColumnType("uuid")
+ .HasColumnName("shocker_id");
+
+ b.HasKey("Id")
+ .HasName("shocker_share_codes_pkey");
+
+ b.HasIndex("ShockerId");
+
+ b.ToTable("shocker_share_codes", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ActivatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("activated_at");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)")
+ .HasColumnName("email");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("name")
+ .UseCollation("ndcoll");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("password_hash");
+
+ b.PrimitiveCollection>("Roles")
+ .IsRequired()
+ .HasColumnType("role_type[]")
+ .HasColumnName("roles");
+
+ b.HasKey("Id")
+ .HasName("users_pkey");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Name"), new[] { "ndcoll" });
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("EmailSendAttempts")
+ .HasColumnType("integer")
+ .HasColumnName("email_send_attempts");
+
+ b.Property("SecretHash")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("secret");
+
+ b.HasKey("UserId")
+ .HasName("user_activation_requests_pkey");
+
+ b.ToTable("user_activation_requests", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b =>
+ {
+ b.Property("DeactivatedUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("deactivated_user_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("DeactivatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("deactivated_by_user_id");
+
+ b.Property("DeleteLater")
+ .HasColumnType("boolean")
+ .HasColumnName("delete_later");
+
+ b.Property("UserModerationId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_moderation_id");
+
+ b.HasKey("DeactivatedUserId")
+ .HasName("user_deactivations_pkey");
+
+ b.HasIndex("DeactivatedByUserId");
+
+ b.ToTable("user_deactivations", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)")
+ .HasColumnName("email");
+
+ b.Property("SecretHash")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("secret");
+
+ b.Property("UsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("used_at");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("user_email_changes_pkey");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("UsedAt");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("user_email_changes", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("OldName")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("old_name");
+
+ b.HasKey("Id", "UserId")
+ .HasName("user_name_changes_pkey");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("OldName");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("user_name_changes", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("SecretHash")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("secret");
+
+ b.Property("UsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("used_at");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("user_password_resets_pkey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("user_password_resets", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b =>
+ {
+ b.Property("SharedWithUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("shared_with_user_id");
+
+ b.Property("ShockerId")
+ .HasColumnType("uuid")
+ .HasColumnName("shocker_id");
+
+ b.Property("AllowLiveControl")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_livecontrol");
+
+ b.Property("AllowShock")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_shock");
+
+ b.Property("AllowSound")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_sound");
+
+ b.Property("AllowVibrate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_vibrate");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("IsPaused")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("is_paused");
+
+ b.Property("MaxDuration")
+ .HasColumnType("integer")
+ .HasColumnName("max_duration");
+
+ b.Property("MaxIntensity")
+ .HasColumnType("smallint")
+ .HasColumnName("max_intensity");
+
+ b.HasKey("SharedWithUserId", "ShockerId")
+ .HasName("user_shares_pkey");
+
+ b.HasIndex("SharedWithUserId");
+
+ b.HasIndex("ShockerId");
+
+ b.ToTable("user_shares", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("OwnerId")
+ .HasColumnType("uuid")
+ .HasColumnName("owner_id");
+
+ b.Property("RecipientUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("user_share_invites_pkey");
+
+ b.HasIndex("OwnerId");
+
+ b.HasIndex("RecipientUserId");
+
+ b.ToTable("user_share_invites", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b =>
+ {
+ b.Property("InviteId")
+ .HasColumnType("uuid")
+ .HasColumnName("invite_id");
+
+ b.Property("ShockerId")
+ .HasColumnType("uuid")
+ .HasColumnName("shocker_id");
+
+ b.Property("AllowLiveControl")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_livecontrol");
+
+ b.Property("AllowShock")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_shock");
+
+ b.Property("AllowSound")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_sound");
+
+ b.Property("AllowVibrate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("allow_vibrate");
+
+ b.Property("IsPaused")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("is_paused");
+
+ b.Property("MaxDuration")
+ .HasColumnType("integer")
+ .HasColumnName("max_duration");
+
+ b.Property("MaxIntensity")
+ .HasColumnType("smallint")
+ .HasColumnName("max_intensity");
+
+ b.HasKey("InviteId", "ShockerId")
+ .HasName("user_share_invite_shockers_pkey");
+
+ b.HasIndex("ShockerId");
+
+ b.ToTable("user_share_invite_shockers", (string)null);
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "User")
+ .WithMany("ApiTokens")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_api_tokens_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner")
+ .WithMany("Devices")
+ .HasForeignKey("OwnerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_devices_owner_id");
+
+ b.Navigation("Owner");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device")
+ .WithMany("OtaUpdates")
+ .HasForeignKey("DeviceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_device_ota_updates_device_id");
+
+ b.Navigation("Device");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner")
+ .WithMany("OwnedPublicShares")
+ .HasForeignKey("OwnerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_public_shares_owner_id");
+
+ b.Navigation("Owner");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.PublicShare", "PublicShare")
+ .WithMany("ShockerMappings")
+ .HasForeignKey("PublicShareId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_public_share_shockers_public_share_id");
+
+ b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker")
+ .WithMany("PublicShareMappings")
+ .HasForeignKey("ShockerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_public_share_shockers_shocker_id");
+
+ b.Navigation("PublicShare");
+
+ b.Navigation("Shocker");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device")
+ .WithMany("Shockers")
+ .HasForeignKey("DeviceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_shockers_device_id");
+
+ b.Navigation("Device");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "ControlledByUser")
+ .WithMany("ShockerControlLogs")
+ .HasForeignKey("ControlledByUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .HasConstraintName("fk_shocker_control_logs_controlled_by_user_id");
+
+ b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker")
+ .WithMany("ShockerControlLogs")
+ .HasForeignKey("ShockerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_shocker_control_logs_shocker_id");
+
+ b.Navigation("ControlledByUser");
+
+ b.Navigation("Shocker");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker")
+ .WithMany("ShockerShareCodes")
+ .HasForeignKey("ShockerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_shocker_share_codes_shocker_id");
+
+ b.Navigation("Shocker");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "User")
+ .WithOne("UserActivationRequest")
+ .HasForeignKey("OpenShock.Common.OpenShockDb.UserActivationRequest", "UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_activation_requests_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedByUser")
+ .WithMany()
+ .HasForeignKey("DeactivatedByUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_deactivations_deactivated_by_user_id");
+
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedUser")
+ .WithOne("UserDeactivation")
+ .HasForeignKey("OpenShock.Common.OpenShockDb.UserDeactivation", "DeactivatedUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_deactivations_deactivated_user_id");
+
+ b.Navigation("DeactivatedByUser");
+
+ b.Navigation("DeactivatedUser");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "User")
+ .WithMany("EmailChanges")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_email_changes_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "User")
+ .WithMany("NameChanges")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_name_changes_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "User")
+ .WithMany("PasswordResets")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_password_resets_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "SharedWithUser")
+ .WithMany("IncomingUserShares")
+ .HasForeignKey("SharedWithUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_shares_shared_with_user_id");
+
+ b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker")
+ .WithMany("UserShares")
+ .HasForeignKey("ShockerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_shares_shocker_id");
+
+ b.Navigation("SharedWithUser");
+
+ b.Navigation("Shocker");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner")
+ .WithMany("OutgoingUserShareInvites")
+ .HasForeignKey("OwnerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_share_invites_owner_id");
+
+ b.HasOne("OpenShock.Common.OpenShockDb.User", "RecipientUser")
+ .WithMany("IncomingUserShareInvites")
+ .HasForeignKey("RecipientUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .HasConstraintName("fk_user_share_invites_recipient_user_id");
+
+ b.Navigation("Owner");
+
+ b.Navigation("RecipientUser");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b =>
+ {
+ b.HasOne("OpenShock.Common.OpenShockDb.UserShareInvite", "Invite")
+ .WithMany("ShockerMappings")
+ .HasForeignKey("InviteId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_share_invite_shockers_invite_id");
+
+ b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker")
+ .WithMany("UserShareInviteShockerMappings")
+ .HasForeignKey("ShockerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_share_invite_shockers_shocker_id");
+
+ b.Navigation("Invite");
+
+ b.Navigation("Shocker");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b =>
+ {
+ b.Navigation("OtaUpdates");
+
+ b.Navigation("Shockers");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b =>
+ {
+ b.Navigation("ShockerMappings");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b =>
+ {
+ b.Navigation("PublicShareMappings");
+
+ b.Navigation("ShockerControlLogs");
+
+ b.Navigation("ShockerShareCodes");
+
+ b.Navigation("UserShareInviteShockerMappings");
+
+ b.Navigation("UserShares");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b =>
+ {
+ b.Navigation("ApiTokens");
+
+ b.Navigation("Devices");
+
+ b.Navigation("EmailChanges");
+
+ b.Navigation("IncomingUserShareInvites");
+
+ b.Navigation("IncomingUserShares");
+
+ b.Navigation("NameChanges");
+
+ b.Navigation("OutgoingUserShareInvites");
+
+ b.Navigation("OwnedPublicShares");
+
+ b.Navigation("PasswordResets");
+
+ b.Navigation("ShockerControlLogs");
+
+ b.Navigation("UserActivationRequest");
+
+ b.Navigation("UserDeactivation");
+ });
+
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b =>
+ {
+ b.Navigation("ShockerMappings");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Common/Migrations/20250525165800_AddWebhooksTable.cs b/Common/Migrations/20250525165800_AddWebhooksTable.cs
new file mode 100644
index 00000000..c5a310dc
--- /dev/null
+++ b/Common/Migrations/20250525165800_AddWebhooksTable.cs
@@ -0,0 +1,37 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace OpenShock.Common.Migrations
+{
+ ///
+ public partial class AddWebhooksTable : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "discord_webhooks",
+ columns: table => new
+ {
+ name = table.Column(type: "text", nullable: false),
+ id = table.Column(type: "uuid", nullable: false),
+ webhook_id = table.Column(type: "bigint", nullable: false),
+ webhook_token = table.Column(type: "text", nullable: false),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("discord_webhooks_pkey", x => x.name);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "discord_webhooks");
+ }
+ }
+}
diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs
index c714689a..12d9ebb5 100644
--- a/Common/Migrations/OpenShockContextModelSnapshot.cs
+++ b/Common/Migrations/OpenShockContextModelSnapshot.cs
@@ -254,6 +254,37 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("device_ota_updates", (string)null);
});
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b =>
+ {
+ b.Property("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("WebhookId")
+ .HasColumnType("bigint")
+ .HasColumnName("webhook_id");
+
+ b.Property("WebhookToken")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("webhook_token");
+
+ b.HasKey("Name")
+ .HasName("discord_webhooks_pkey");
+
+ b.ToTable("discord_webhooks", (string)null);
+ });
+
modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b =>
{
b.Property("Id")
diff --git a/Common/Models/WebhookDto.cs b/Common/Models/WebhookDto.cs
new file mode 100644
index 00000000..a66e9950
--- /dev/null
+++ b/Common/Models/WebhookDto.cs
@@ -0,0 +1,9 @@
+namespace OpenShock.Common.Models;
+
+public sealed class WebhookDto
+{
+ public required Guid Id { get; set; }
+ public required string Name { get; set; }
+ public required string Url { get; set; }
+ public required DateTimeOffset CreatedAt { get; set; }
+}
\ No newline at end of file
diff --git a/Common/OpenShockDb/DiscordWebhook.cs b/Common/OpenShockDb/DiscordWebhook.cs
new file mode 100644
index 00000000..aa1a07e8
--- /dev/null
+++ b/Common/OpenShockDb/DiscordWebhook.cs
@@ -0,0 +1,14 @@
+namespace OpenShock.Common.OpenShockDb;
+
+public sealed class DiscordWebhook
+{
+ public required Guid Id { get; set; }
+
+ public required string Name { get; set; }
+
+ public required long WebhookId { get; set; }
+
+ public required string WebhookToken { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+}
\ No newline at end of file
diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs
index 73305649..51c1bbfc 100644
--- a/Common/OpenShockDb/OpenShockContext.cs
+++ b/Common/OpenShockDb/OpenShockContext.cs
@@ -109,6 +109,8 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde
public DbSet UserEmailChanges { get; set; }
public DbSet UserNameChanges { get; set; }
+
+ public DbSet DiscordWebhooks { get; set; }
public DbSet AdminUsersViews { get; set; }
@@ -660,6 +662,25 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.HasConstraintName("fk_user_name_changes_user_id");
});
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Name).HasName("discord_webhooks_pkey");
+
+ entity.ToTable("discord_webhooks");
+
+ entity.Property(e => e.Id)
+ .HasColumnName("id");
+ entity.Property(e => e.Name)
+ .HasColumnName("name");
+ entity.Property(e => e.WebhookId)
+ .HasColumnName("webhook_id");
+ entity.Property(e => e.WebhookToken)
+ .HasColumnName("webhook_token");
+ entity.Property(e => e.CreatedAt)
+ .HasDefaultValueSql("CURRENT_TIMESTAMP")
+ .HasColumnName("created_at");
+ });
+
modelBuilder.Entity(entity =>
{
entity
diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs
index c325590a..439f1831 100644
--- a/Common/OpenShockServiceHelper.cs
+++ b/Common/OpenShockServiceHelper.cs
@@ -15,6 +15,7 @@
using OpenShock.Common.Services.BatchUpdate;
using OpenShock.Common.Services.RedisPubSub;
using OpenShock.Common.Services.Session;
+using OpenShock.Common.Services.Webhook;
using OpenTelemetry.Metrics;
using Redis.OM;
using Redis.OM.Contracts;
@@ -189,6 +190,7 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se
// <---- OpenShock Services ---->
services.AddScoped();
+ services.AddSingleton();
services.AddSingleton();
services.AddHostedService(provider =>
(BatchUpdateService)provider.GetRequiredService());
diff --git a/Common/Services/Webhook/IWebhookService.cs b/Common/Services/Webhook/IWebhookService.cs
new file mode 100644
index 00000000..44346c45
--- /dev/null
+++ b/Common/Services/Webhook/IWebhookService.cs
@@ -0,0 +1,18 @@
+using System.Drawing;
+using OneOf;
+using OneOf.Types;
+using OpenShock.Common.Models;
+
+namespace OpenShock.Common.Services.Webhook;
+
+public interface IWebhookService
+{
+ public Task, UnsupportedWebhookUrl>> AddWebhook(string name, Uri webhookUrl);
+ public Task RemoveWebhook(Guid webhookId);
+ public Task GetWebhooks();
+
+ public Task> SendWebhook(string webhookName, string title, string content, Color color);
+}
+
+public struct UnsupportedWebhookUrl;
+public struct WebhookTimeout;
\ No newline at end of file
diff --git a/Common/Services/Webhook/WebhookService.cs b/Common/Services/Webhook/WebhookService.cs
new file mode 100644
index 00000000..9f0b7a9e
--- /dev/null
+++ b/Common/Services/Webhook/WebhookService.cs
@@ -0,0 +1,126 @@
+using System.Drawing;
+using Microsoft.EntityFrameworkCore;
+using OneOf;
+using OneOf.Types;
+using OpenShock.Common.Models;
+using OpenShock.Common.OpenShockDb;
+
+namespace OpenShock.Common.Services.Webhook;
+
+public sealed class WebhookService : IWebhookService
+{
+ private readonly OpenShockContext _db;
+ private readonly IHttpClientFactory _httpClientFactory;
+
+ public WebhookService(OpenShockContext db, IHttpClientFactory httpClientFactory)
+ {
+ _db = db;
+ _httpClientFactory = httpClientFactory;
+ }
+
+ private static string GetWebhookUrl(long webhookId, string webhookToken) =>
+ $"https://discord.com/api/webhooks/{webhookId}/{webhookToken}";
+
+ public async Task, UnsupportedWebhookUrl>> AddWebhook(string name, Uri webhookUrl)
+ {
+ if (webhookUrl is not
+ {
+ Scheme: "https",
+ DnsSafeHost: "discord.com",
+ Segments: ["/", "api/", "webhooks/", {} webhookIdStr, {} webhookToken]
+ } ||
+ !long.TryParse(webhookIdStr, out var webhookId)
+ )
+ {
+ return new UnsupportedWebhookUrl();
+ }
+
+ var webhook = new DiscordWebhook
+ {
+ Id = Guid.CreateVersion7(),
+ Name = name,
+ WebhookId = webhookId,
+ WebhookToken = webhookToken,
+ };
+
+ _db.DiscordWebhooks.Add(webhook);
+
+ await _db.SaveChangesAsync();
+
+ return new Success(new WebhookDto
+ {
+ Id = webhook.Id,
+ Name = webhook.Name,
+ Url = GetWebhookUrl(webhook.WebhookId, webhook.WebhookToken),
+ CreatedAt = webhook.CreatedAt
+ });
+ }
+
+ public async Task RemoveWebhook(Guid webhookId)
+ {
+ var nDeleted = await _db.DiscordWebhooks.Where(w => w.Id == webhookId).ExecuteDeleteAsync();
+ return nDeleted > 0;
+ }
+
+ public async Task GetWebhooks()
+ {
+ return await _db.DiscordWebhooks
+ .OrderByDescending(w => w.CreatedAt)
+ .Select(w => new WebhookDto
+ {
+ Id = w.Id,
+ Name = w.Name,
+ Url = GetWebhookUrl(w.WebhookId, w.WebhookToken),
+ CreatedAt = w.CreatedAt
+ })
+ .ToArrayAsync();
+ }
+
+ public async Task> SendWebhook(string webhookName, string title, string content, Color color)
+ {
+ var webhook = await _db.DiscordWebhooks
+ .Where(w => w.Name == webhookName)
+ .FirstOrDefaultAsync();
+
+ if (webhook is null)
+ return new NotFound();
+
+ // Do not dispose of HttpClient created from factory
+#pragma warning disable IDISP001
+ var httpClient = _httpClientFactory.CreateClient();
+#pragma warning restore IDISP001
+ httpClient.Timeout = TimeSpan.FromSeconds(10);
+
+ var embed = new
+ {
+ title,
+ description = content,
+ color = (color.R << 16) | (color.G << 8) | color.B // RGB to Discord int
+ };
+
+ var payload = new
+ {
+ embeds = new[] { embed }
+ };
+
+ try
+ {
+ using var response = await httpClient.PostAsJsonAsync(
+ GetWebhookUrl(webhook.WebhookId, webhook.WebhookToken),
+ payload);
+
+ if (!response.IsSuccessStatusCode)
+ return new Error(); // Consider inspecting status code if you want more granularity
+
+ return new Success();
+ }
+ catch (TaskCanceledException ex) when (!ex.CancellationToken.IsCancellationRequested)
+ {
+ return new WebhookTimeout();
+ }
+ catch (Exception)
+ {
+ return new Error();
+ }
+ }
+}
\ No newline at end of file