diff --git a/backend/Data/AppDbContext.cs b/backend/Data/AppDbContext.cs index 3698a23..a73683e 100644 --- a/backend/Data/AppDbContext.cs +++ b/backend/Data/AppDbContext.cs @@ -1,4 +1,6 @@ using backend.Auth; +using backend.Devices; +using backend.Settings; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; @@ -8,4 +10,22 @@ namespace backend.Data; public class AppDbContext(DbContextOptions options) : IdentityDbContext, Guid>(options) { + public DbSet Devices => Set(); + public DbSet DeviceConfigs => Set(); + public DbSet SystemSettings => Set(); + + protected override void OnModelCreating(ModelBuilder b) + { + base.OnModelCreating(b); + + b.Entity().HasIndex(d => d.OwnerUserId); + b.Entity().HasIndex(d => d.TokenHash).IsUnique(); + b.Entity() + .HasOne(d => d.Config) + .WithOne() + .HasForeignKey(c => c.DeviceId) + .OnDelete(DeleteBehavior.Cascade); + b.Entity().HasKey(c => c.DeviceId); + b.Entity().HasKey(s => s.Id); + } } diff --git a/backend/Devices/Device.cs b/backend/Devices/Device.cs new file mode 100644 index 0000000..3158ade --- /dev/null +++ b/backend/Devices/Device.cs @@ -0,0 +1,13 @@ +namespace backend.Devices; + +public class Device +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid OwnerUserId { get; set; } + public string Name { get; set; } = ""; + public DateTimeOffset PairedAt { get; set; } + public DateTimeOffset? LastSeenAt { get; set; } + public string TokenHash { get; set; } = ""; + public bool IsRevoked { get; set; } + public DeviceConfig? Config { get; set; } +} diff --git a/backend/Devices/DeviceConfig.cs b/backend/Devices/DeviceConfig.cs new file mode 100644 index 0000000..c56baaa --- /dev/null +++ b/backend/Devices/DeviceConfig.cs @@ -0,0 +1,11 @@ +namespace backend.Devices; + +public class DeviceConfig +{ + public Guid DeviceId { get; set; } + public string SystemPrompt { get; set; } = ""; + public string Voice { get; set; } = ""; + public string Model { get; set; } = ""; + public int IdleTimeoutSeconds { get; set; } = 30; + public string EnabledToolsJson { get; set; } = "[\"end_session\",\"get_current_time\",\"set_volume\"]"; +} diff --git a/backend/Migrations/20260611184053_AddDevicesAndSettings.Designer.cs b/backend/Migrations/20260611184053_AddDevicesAndSettings.Designer.cs new file mode 100644 index 0000000..9ddc0cd --- /dev/null +++ b/backend/Migrations/20260611184053_AddDevicesAndSettings.Designer.cs @@ -0,0 +1,372 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using backend.Data; + +#nullable disable + +namespace backend.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260611184053_AddDevicesAndSettings")] + partial class AddDevicesAndSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.17"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + 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("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("backend.Auth.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("backend.Devices.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("IsRevoked") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("PairedAt") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("backend.Devices.DeviceConfig", b => + { + b.Property("DeviceId") + .HasColumnType("TEXT"); + + b.Property("EnabledToolsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IdleTimeoutSeconds") + .HasColumnType("INTEGER"); + + b.Property("Model") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SystemPrompt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Voice") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("DeviceId"); + + b.ToTable("DeviceConfigs"); + }); + + modelBuilder.Entity("backend.Settings.SystemSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DefaultIdleTimeoutSeconds") + .HasColumnType("INTEGER"); + + b.Property("DefaultModel") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DefaultSystemPrompt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DefaultVoice") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SystemSettings"); + }); + + 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("backend.Auth.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("backend.Auth.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("backend.Auth.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("backend.Auth.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("backend.Devices.DeviceConfig", b => + { + b.HasOne("backend.Devices.Device", null) + .WithOne("Config") + .HasForeignKey("backend.Devices.DeviceConfig", "DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("backend.Devices.Device", b => + { + b.Navigation("Config"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Migrations/20260611184053_AddDevicesAndSettings.cs b/backend/Migrations/20260611184053_AddDevicesAndSettings.cs new file mode 100644 index 0000000..433e4a6 --- /dev/null +++ b/backend/Migrations/20260611184053_AddDevicesAndSettings.cs @@ -0,0 +1,94 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace backend.Migrations +{ + /// + public partial class AddDevicesAndSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Devices", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + OwnerUserId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + PairedAt = table.Column(type: "TEXT", nullable: false), + LastSeenAt = table.Column(type: "TEXT", nullable: true), + TokenHash = table.Column(type: "TEXT", nullable: false), + IsRevoked = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Devices", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SystemSettings", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + DefaultSystemPrompt = table.Column(type: "TEXT", nullable: false), + DefaultVoice = table.Column(type: "TEXT", nullable: false), + DefaultModel = table.Column(type: "TEXT", nullable: false), + DefaultIdleTimeoutSeconds = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SystemSettings", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "DeviceConfigs", + columns: table => new + { + DeviceId = table.Column(type: "TEXT", nullable: false), + SystemPrompt = table.Column(type: "TEXT", nullable: false), + Voice = table.Column(type: "TEXT", nullable: false), + Model = table.Column(type: "TEXT", nullable: false), + IdleTimeoutSeconds = table.Column(type: "INTEGER", nullable: false), + EnabledToolsJson = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DeviceConfigs", x => x.DeviceId); + table.ForeignKey( + name: "FK_DeviceConfigs_Devices_DeviceId", + column: x => x.DeviceId, + principalTable: "Devices", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Devices_OwnerUserId", + table: "Devices", + column: "OwnerUserId"); + + migrationBuilder.CreateIndex( + name: "IX_Devices_TokenHash", + table: "Devices", + column: "TokenHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DeviceConfigs"); + + migrationBuilder.DropTable( + name: "SystemSettings"); + + migrationBuilder.DropTable( + name: "Devices"); + } + } +} diff --git a/backend/Migrations/AppDbContextModelSnapshot.cs b/backend/Migrations/AppDbContextModelSnapshot.cs index 6bc4363..6ce3e2e 100644 --- a/backend/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/Migrations/AppDbContextModelSnapshot.cs @@ -208,6 +208,97 @@ namespace backend.Migrations b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("backend.Devices.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("IsRevoked") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("PairedAt") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("backend.Devices.DeviceConfig", b => + { + b.Property("DeviceId") + .HasColumnType("TEXT"); + + b.Property("EnabledToolsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IdleTimeoutSeconds") + .HasColumnType("INTEGER"); + + b.Property("Model") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SystemPrompt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Voice") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("DeviceId"); + + b.ToTable("DeviceConfigs"); + }); + + modelBuilder.Entity("backend.Settings.SystemSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DefaultIdleTimeoutSeconds") + .HasColumnType("INTEGER"); + + b.Property("DefaultModel") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DefaultSystemPrompt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DefaultVoice") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SystemSettings"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) @@ -258,6 +349,20 @@ namespace backend.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); + + modelBuilder.Entity("backend.Devices.DeviceConfig", b => + { + b.HasOne("backend.Devices.Device", null) + .WithOne("Config") + .HasForeignKey("backend.Devices.DeviceConfig", "DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("backend.Devices.Device", b => + { + b.Navigation("Config"); + }); #pragma warning restore 612, 618 } } diff --git a/backend/Program.cs b/backend/Program.cs index 3dcfb4e..bf2df6b 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -15,8 +15,15 @@ var app = builder.Build(); using (var scope = app.Services.CreateScope()) { var sp = scope.ServiceProvider; - sp.GetRequiredService().Database.Migrate(); + var db = sp.GetRequiredService(); + db.Database.Migrate(); await IdentitySetup.SeedRolesAsync(sp); + + if (!db.SystemSettings.Any()) + { + db.SystemSettings.Add(new backend.Settings.SystemSettings()); + await db.SaveChangesAsync(); + } } app.UseAuthentication(); diff --git a/backend/Settings/SystemSettings.cs b/backend/Settings/SystemSettings.cs new file mode 100644 index 0000000..650b1de --- /dev/null +++ b/backend/Settings/SystemSettings.cs @@ -0,0 +1,11 @@ +namespace backend.Settings; + +public class SystemSettings +{ + public int Id { get; set; } = 1; + public string DefaultSystemPrompt { get; set; } = + "You are a helpful voice assistant. Keep responses concise."; + public string DefaultVoice { get; set; } = "alloy"; + public string DefaultModel { get; set; } = "gpt-4o-realtime-preview"; + public int DefaultIdleTimeoutSeconds { get; set; } = 30; +}