using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; using System.Net; using System.Text.Json; using Content.Shared.Database; using Microsoft.EntityFrameworkCore; using NpgsqlTypes; namespace Content.Server.Database { public abstract class ServerDbContext : DbContext { protected ServerDbContext(DbContextOptions options) : base(options) { } public DbSet Preference { get; set; } = null!; public DbSet Profile { get; set; } = null!; public DbSet AssignedUserId { get; set; } = null!; public DbSet Player { get; set; } = default!; public DbSet Admin { get; set; } = null!; public DbSet AdminRank { get; set; } = null!; public DbSet Round { get; set; } = null!; public DbSet Server { get; set; } = null!; public DbSet AdminLog { get; set; } = null!; public DbSet AdminLogPlayer { get; set; } = null!; public DbSet Whitelist { get; set; } = null!; public DbSet Ban { get; set; } = default!; public DbSet Unban { get; set; } = default!; public DbSet BanExemption { get; set; } = default!; public DbSet ConnectionLog { get; set; } = default!; public DbSet ServerBanHit { get; set; } = default!; public DbSet RoleBan { get; set; } = default!; public DbSet RoleUnban { get; set; } = default!; public DbSet PlayTime { get; set; } = default!; public DbSet UploadedResourceLog { get; set; } = default!; public DbSet AdminNotes { get; set; } = null!; public DbSet AdminWatchlists { get; set; } = null!; public DbSet AdminMessages { get; set; } = null!; protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity() .HasIndex(p => p.UserId) .IsUnique(); modelBuilder.Entity() .HasIndex(p => new {p.Slot, PrefsId = p.PreferenceId}) .IsUnique(); modelBuilder.Entity() .HasIndex(p => new {HumanoidProfileId = p.ProfileId, p.AntagName}) .IsUnique(); modelBuilder.Entity() .HasIndex(p => new {HumanoidProfileId = p.ProfileId, p.TraitName}) .IsUnique(); modelBuilder.Entity() .HasIndex(j => j.ProfileId); modelBuilder.Entity() .HasIndex(j => j.ProfileId, "IX_job_one_high_priority") .IsUnique() .HasFilter("priority = 3"); modelBuilder.Entity() .HasIndex(j => new { j.ProfileId, j.JobName }) .IsUnique(); modelBuilder.Entity() .HasIndex(p => p.UserName) .IsUnique(); // Can't have two usernames with the same user ID. modelBuilder.Entity() .HasIndex(p => p.UserId) .IsUnique(); modelBuilder.Entity() .HasOne(p => p.AdminRank) .WithMany(p => p!.Admins) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasIndex(f => new {f.Flag, f.AdminId}) .IsUnique(); modelBuilder.Entity() .HasIndex(f => new {f.Flag, f.AdminRankId}) .IsUnique(); modelBuilder.Entity() .HasKey(log => new {log.RoundId, log.Id}); modelBuilder.Entity() .Property(log => log.Id); modelBuilder.Entity() .HasIndex(log => log.Date); modelBuilder.Entity() .HasIndex(v => new { v.PlayerId, Role = v.Tracker }) .IsUnique(); modelBuilder.Entity() .HasOne(player => player.Player) .WithMany(player => player.AdminLogs) .HasForeignKey(player => player.PlayerUserId) .HasPrincipalKey(player => player.UserId); modelBuilder.Entity() .HasIndex(p => p.PlayerUserId); modelBuilder.Entity() .HasIndex(round => round.StartDate); modelBuilder.Entity() .HasKey(logPlayer => new {logPlayer.RoundId, logPlayer.LogId, logPlayer.PlayerUserId}); modelBuilder.Entity() .HasIndex(p => p.PlayerUserId); modelBuilder.Entity() .HasIndex(p => p.Address); modelBuilder.Entity() .HasIndex(p => p.PlayerUserId); modelBuilder.Entity() .HasIndex(p => p.BanId) .IsUnique(); modelBuilder.Entity().ToTable(t => t.HasCheckConstraint("HaveEitherAddressOrUserIdOrHWId", "address IS NOT NULL OR player_user_id IS NOT NULL OR hwid IS NOT NULL")); // Ban exemption can't have flags 0 since that wouldn't exempt anything. // The row should be removed if setting to 0. modelBuilder.Entity().ToTable(t => t.HasCheckConstraint("FlagsNotZero", "flags != 0")); modelBuilder.Entity() .HasIndex(p => p.PlayerUserId); modelBuilder.Entity() .HasIndex(p => p.Address); modelBuilder.Entity() .HasIndex(p => p.PlayerUserId); modelBuilder.Entity() .HasIndex(p => p.BanId) .IsUnique(); modelBuilder.Entity().ToTable(t => t.HasCheckConstraint("HaveEitherAddressOrUserIdOrHWId", "address IS NOT NULL OR player_user_id IS NOT NULL OR hwid IS NOT NULL")); modelBuilder.Entity() .HasIndex(p => p.UserId) .IsUnique(); modelBuilder.Entity() .HasIndex(p => p.LastSeenUserName); modelBuilder.Entity() .HasIndex(p => p.UserId); modelBuilder.Entity() .Property(p => p.ServerId) .HasDefaultValue(0); modelBuilder.Entity() .HasOne(p => p.Server) .WithMany(p => p.ConnectionLogs) .OnDelete(DeleteBehavior.SetNull); // SetNull is necessary for created by/edited by-s here, // so you can safely delete admins (GDPR right to erasure) while keeping the notes intact modelBuilder.Entity() .HasOne(note => note.Player) .WithMany(player => player.AdminNotesReceived) .HasForeignKey(note => note.PlayerUserId) .HasPrincipalKey(player => player.UserId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasOne(version => version.CreatedBy) .WithMany(author => author.AdminNotesCreated) .HasForeignKey(note => note.CreatedById) .HasPrincipalKey(author => author.UserId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasOne(version => version.LastEditedBy) .WithMany(author => author.AdminNotesLastEdited) .HasForeignKey(note => note.LastEditedById) .HasPrincipalKey(author => author.UserId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasOne(version => version.DeletedBy) .WithMany(author => author.AdminNotesDeleted) .HasForeignKey(note => note.DeletedById) .HasPrincipalKey(author => author.UserId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasOne(note => note.Player) .WithMany(player => player.AdminWatchlistsReceived) .HasForeignKey(note => note.PlayerUserId) .HasPrincipalKey(player => player.UserId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasOne(version => version.CreatedBy) .WithMany(author => author.AdminWatchlistsCreated) .HasForeignKey(note => note.CreatedById) .HasPrincipalKey(author => author.UserId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasOne(version => version.LastEditedBy) .WithMany(author => author.AdminWatchlistsLastEdited) .HasForeignKey(note => note.LastEditedById) .HasPrincipalKey(author => author.UserId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasOne(version => version.DeletedBy) .WithMany(author => author.AdminWatchlistsDeleted) .HasForeignKey(note => note.DeletedById) .HasPrincipalKey(author => author.UserId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasOne(note => note.Player) .WithMany(player => player.AdminMessagesReceived) .HasForeignKey(note => note.PlayerUserId) .HasPrincipalKey(player => player.UserId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasOne(version => version.CreatedBy) .WithMany(author => author.AdminMessagesCreated) .HasForeignKey(note => note.CreatedById) .HasPrincipalKey(author => author.UserId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasOne(version => version.LastEditedBy) .WithMany(author => author.AdminMessagesLastEdited) .HasForeignKey(note => note.LastEditedById) .HasPrincipalKey(author => author.UserId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasOne(version => version.DeletedBy) .WithMany(author => author.AdminMessagesDeleted) .HasForeignKey(note => note.DeletedById) .HasPrincipalKey(author => author.UserId) .OnDelete(DeleteBehavior.SetNull); // A message cannot be "dismissed" without also being "seen". modelBuilder.Entity().ToTable(t => t.HasCheckConstraint("NotDismissedAndSeen", "NOT dismissed OR seen")); modelBuilder.Entity() .HasOne(ban => ban.CreatedBy) .WithMany(author => author.AdminServerBansCreated) .HasForeignKey(ban => ban.BanningAdmin) .HasPrincipalKey(author => author.UserId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasOne(ban => ban.LastEditedBy) .WithMany(author => author.AdminServerBansLastEdited) .HasForeignKey(ban => ban.LastEditedById) .HasPrincipalKey(author => author.UserId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasOne(ban => ban.CreatedBy) .WithMany(author => author.AdminServerRoleBansCreated) .HasForeignKey(ban => ban.BanningAdmin) .HasPrincipalKey(author => author.UserId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasOne(ban => ban.LastEditedBy) .WithMany(author => author.AdminServerRoleBansLastEdited) .HasForeignKey(ban => ban.LastEditedById) .HasPrincipalKey(author => author.UserId) .OnDelete(DeleteBehavior.SetNull); } public virtual IQueryable SearchLogs(IQueryable query, string searchText) { return query.Where(log => EF.Functions.Like(log.Message, "%" + searchText + "%")); } public abstract int CountAdminLogs(); } public class Preference { // NOTE: on postgres there SHOULD be an FK ensuring that the selected character slot always exists. // I had to use a migration to implement it and as a result its creation is a finicky mess. // Because if I let EFCore know about it it would explode on a circular reference. // Also it has to be DEFERRABLE INITIALLY DEFERRED so that insertion of new preferences works. // Also I couldn't figure out how to create it on SQLite. public int Id { get; set; } public Guid UserId { get; set; } public int SelectedCharacterSlot { get; set; } public string AdminOOCColor { get; set; } = null!; public List Profiles { get; } = new(); } public class Profile { public int Id { get; set; } public int Slot { get; set; } [Column("char_name")] public string CharacterName { get; set; } = null!; public string FlavorText { get; set; } = null!; public int Age { get; set; } public string Sex { get; set; } = null!; public string Gender { get; set; } = null!; public string Species { get; set; } = null!; [Column(TypeName = "jsonb")] public JsonDocument? Markings { get; set; } = null!; public string HairName { get; set; } = null!; public string HairColor { get; set; } = null!; public string FacialHairName { get; set; } = null!; public string FacialHairColor { get; set; } = null!; public string EyeColor { get; set; } = null!; public string SkinColor { get; set; } = null!; public string Clothing { get; set; } = null!; public string Backpack { get; set; } = null!; public int SpawnPriority { get; set; } = 0; public List Jobs { get; } = new(); public List Antags { get; } = new(); public List Traits { get; } = new(); [Column("pref_unavailable")] public DbPreferenceUnavailableMode PreferenceUnavailable { get; set; } public int PreferenceId { get; set; } public Preference Preference { get; set; } = null!; } public class Job { public int Id { get; set; } public Profile Profile { get; set; } = null!; public int ProfileId { get; set; } public string JobName { get; set; } = null!; public DbJobPriority Priority { get; set; } } public enum DbJobPriority { // These enum values HAVE to match the ones in JobPriority in Content.Shared Never = 0, Low = 1, Medium = 2, High = 3 } public class Antag { public int Id { get; set; } public Profile Profile { get; set; } = null!; public int ProfileId { get; set; } public string AntagName { get; set; } = null!; } public class Trait { public int Id { get; set; } public Profile Profile { get; set; } = null!; public int ProfileId { get; set; } public string TraitName { get; set; } = null!; } public enum DbPreferenceUnavailableMode { // These enum values HAVE to match the ones in PreferenceUnavailableMode in Shared. StayInLobby = 0, SpawnAsOverflow, } public class AssignedUserId { public int Id { get; set; } public string UserName { get; set; } = null!; public Guid UserId { get; set; } } [Table("player")] public class Player { public int Id { get; set; } // Permanent data public Guid UserId { get; set; } public DateTime FirstSeenTime { get; set; } // Data that gets updated on each join. public string LastSeenUserName { get; set; } = null!; public DateTime LastSeenTime { get; set; } public IPAddress LastSeenAddress { get; set; } = null!; public byte[]? LastSeenHWId { get; set; } // Data that changes with each round public List Rounds { get; set; } = null!; public List AdminLogs { get; set; } = null!; public DateTime? LastReadRules { get; set; } public List AdminNotesReceived { get; set; } = null!; public List AdminNotesCreated { get; set; } = null!; public List AdminNotesLastEdited { get; set; } = null!; public List AdminNotesDeleted { get; set; } = null!; public List AdminWatchlistsReceived { get; set; } = null!; public List AdminWatchlistsCreated { get; set; } = null!; public List AdminWatchlistsLastEdited { get; set; } = null!; public List AdminWatchlistsDeleted { get; set; } = null!; public List AdminMessagesReceived { get; set; } = null!; public List AdminMessagesCreated { get; set; } = null!; public List AdminMessagesLastEdited { get; set; } = null!; public List AdminMessagesDeleted { get; set; } = null!; public List AdminServerBansCreated { get; set; } = null!; public List AdminServerBansLastEdited { get; set; } = null!; public List AdminServerRoleBansCreated { get; set; } = null!; public List AdminServerRoleBansLastEdited { get; set; } = null!; } [Table("whitelist")] public class Whitelist { [Required, Key] public Guid UserId { get; set; } } public class Admin { [Key] public Guid UserId { get; set; } public string? Title { get; set; } public int? AdminRankId { get; set; } public AdminRank? AdminRank { get; set; } public List Flags { get; set; } = default!; } public class AdminFlag { public int Id { get; set; } public string Flag { get; set; } = default!; public bool Negative { get; set; } public Guid AdminId { get; set; } public Admin Admin { get; set; } = default!; } public class AdminRank { public int Id { get; set; } public string Name { get; set; } = default!; public List Admins { get; set; } = default!; public List Flags { get; set; } = default!; } public class AdminRankFlag { public int Id { get; set; } public string Flag { get; set; } = default!; public int AdminRankId { get; set; } public AdminRank Rank { get; set; } = default!; } public class Round { [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int Id { get; set; } public DateTime? StartDate { get; set; } public List Players { get; set; } = default!; public List AdminLogs { get; set; } = default!; [ForeignKey("Server")] public int ServerId { get; set; } public Server Server { get; set; } = default!; } public class Server { [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int Id { get; set; } public string Name { get; set; } = default!; [InverseProperty(nameof(Round.Server))] public List Rounds { get; set; } = default!; [InverseProperty(nameof(ConnectionLog.Server))] public List ConnectionLogs { get; set; } = default!; } [Index(nameof(Type))] public class AdminLog { [Key, ForeignKey("Round")] public int RoundId { get; set; } [Key] public int Id { get; set; } public Round Round { get; set; } = default!; [Required] public LogType Type { get; set; } [Required] public LogImpact Impact { get; set; } [Required] public DateTime Date { get; set; } [Required] public string Message { get; set; } = default!; [Required, Column(TypeName = "jsonb")] public JsonDocument Json { get; set; } = default!; public List Players { get; set; } = default!; } public class AdminLogPlayer { [Required, Key] public int RoundId { get; set; } [Required, Key] public int LogId { get; set; } [Required, Key, ForeignKey("Player")] public Guid PlayerUserId { get; set; } public Player Player { get; set; } = default!; [ForeignKey("RoundId,LogId")] public AdminLog Log { get; set; } = default!; } // Used by SS14.Admin public interface IBanCommon where TUnban : IUnbanCommon { int Id { get; set; } Guid? PlayerUserId { get; set; } NpgsqlInet? Address { get; set; } byte[]? HWId { get; set; } DateTime BanTime { get; set; } DateTime? ExpirationTime { get; set; } string Reason { get; set; } NoteSeverity Severity { get; set; } Guid? BanningAdmin { get; set; } TUnban? Unban { get; set; } } // Used by SS14.Admin public interface IUnbanCommon { int Id { get; set; } int BanId { get; set; } Guid? UnbanningAdmin { get; set; } DateTime UnbanTime { get; set; } } /// /// Flags for use with . /// [Flags] public enum ServerBanExemptFlags { // @formatter:off None = 0, /// /// Ban is a datacenter range, connections usually imply usage of a VPN service. /// Datacenter = 1 << 0, /// /// Ban only matches the IP. /// /// /// Intended use is for users with shared connections. This should not be used as an alternative to . /// IP = 1 << 1, // @formatter:on } /// /// A ban from playing on the server. /// If an incoming connection matches any of UserID, IP, or HWID, they will be blocked from joining the server. /// /// /// At least one of UserID, IP, or HWID must be given (otherwise the ban would match nothing). /// [Table("server_ban"), Index(nameof(PlayerUserId))] public class ServerBan : IBanCommon { public int Id { get; set; } [ForeignKey("Round")] public int? RoundId { get; set; } public Round? Round { get; set; } /// /// The user ID of the banned player. /// public Guid? PlayerUserId { get; set; } [Required] public TimeSpan PlaytimeAtNote { get; set; } /// /// CIDR IP address range of the ban. The whole range can match the ban. /// public NpgsqlInet? Address { get; set; } /// /// Hardware ID of the banned player. /// public byte[]? HWId { get; set; } /// /// The time when the ban was applied by an administrator. /// public DateTime BanTime { get; set; } /// /// The time the ban will expire. If null, the ban is permanent and will not expire naturally. /// public DateTime? ExpirationTime { get; set; } /// /// The administrator-stated reason for applying the ban. /// public string Reason { get; set; } = null!; /// /// The severity of the incident /// public NoteSeverity Severity { get; set; } /// /// User ID of the admin that applied the ban. /// [ForeignKey("CreatedBy")] public Guid? BanningAdmin { get; set; } public Player? CreatedBy { get; set; } /// /// User ID of the admin that last edited the note /// [ForeignKey("LastEditedBy")] public Guid? LastEditedById { get; set; } public Player? LastEditedBy { get; set; } /// /// When the ban was last edited /// public DateTime? LastEditedAt { get; set; } /// /// Optional flags that allow adding exemptions to the ban via . /// public ServerBanExemptFlags ExemptFlags { get; set; } /// /// If present, an administrator has manually repealed this ban. /// public ServerUnban? Unban { get; set; } /// /// Whether this ban should be automatically deleted from the database when it expires. /// /// /// This isn't done automatically by the game, /// you will need to set up something like a cron job to clear this from your database, /// using a command like this: /// psql -d ss14 -c "DELETE FROM server_ban WHERE auto_delete AND expiration_time < NOW()" /// public bool AutoDelete { get; set; } /// /// Whether to display this ban in the admin remarks (notes) panel /// public bool Hidden { get; set; } public List BanHits { get; set; } = null!; } /// /// An explicit repeal of a by an administrator. /// Having an entry for a ban neutralizes it. /// [Table("server_unban")] public class ServerUnban : IUnbanCommon { [Column("unban_id")] public int Id { get; set; } /// /// The ID of ban that is being repealed. /// public int BanId { get; set; } /// /// The ban that is being repealed. /// public ServerBan Ban { get; set; } = null!; /// /// The admin that repealed the ban. /// public Guid? UnbanningAdmin { get; set; } /// /// The time the ban repealed. /// public DateTime UnbanTime { get; set; } } /// /// An exemption for a specific user to a certain type of . /// /// /// Certain players may need to be exempted from VPN bans due to issues with their ISP. /// We would tag all VPN bans with , /// and then add an exemption for these players to this table with the same flag. /// They will only be exempted from VPN bans, other bans (if they manage to get any) will still apply. /// [Table("server_ban_exemption")] public sealed class ServerBanExemption { /// /// The UserID of the exempted player. /// [Key] public Guid UserId { get; set; } /// /// The ban flags to exempt this player from. /// If any bit overlaps , the ban is ignored. /// public ServerBanExemptFlags Flags { get; set; } } [Table("connection_log")] public class ConnectionLog { public int Id { get; set; } public Guid UserId { get; set; } public string UserName { get; set; } = null!; public DateTime Time { get; set; } public IPAddress Address { get; set; } = null!; public byte[]? HWId { get; set; } public ConnectionDenyReason? Denied { get; set; } /// /// ID of the that the connection was attempted to. /// /// /// /// The default value of this column is set to 0, which is the ID of the "unknown" server. /// This is intended for old entries (that didn't track this) and if the server name isn't configured. /// /// public int ServerId { get; set; } public List BanHits { get; set; } = null!; public Server Server { get; set; } = null!; } public enum ConnectionDenyReason : byte { Ban = 0, Whitelist = 1, Full = 2, Panic = 3, } public class ServerBanHit { public int Id { get; set; } public int BanId { get; set; } public int ConnectionId { get; set; } public ServerBan Ban { get; set; } = null!; public ConnectionLog Connection { get; set; } = null!; } [Table("server_role_ban"), Index(nameof(PlayerUserId))] public sealed class ServerRoleBan : IBanCommon { public int Id { get; set; } public int? RoundId { get; set; } public Round? Round { get; set; } public Guid? PlayerUserId { get; set; } [Required] public TimeSpan PlaytimeAtNote { get; set; } public NpgsqlInet? Address { get; set; } public byte[]? HWId { get; set; } public DateTime BanTime { get; set; } public DateTime? ExpirationTime { get; set; } public string Reason { get; set; } = null!; public NoteSeverity Severity { get; set; } [ForeignKey("CreatedBy")] public Guid? BanningAdmin { get; set; } public Player? CreatedBy { get; set; } [ForeignKey("LastEditedBy")] public Guid? LastEditedById { get; set; } public Player? LastEditedBy { get; set; } public DateTime? LastEditedAt { get; set; } public ServerRoleUnban? Unban { get; set; } public bool Hidden { get; set; } public string RoleId { get; set; } = null!; } [Table("server_role_unban")] public sealed class ServerRoleUnban : IUnbanCommon { [Column("role_unban_id")] public int Id { get; set; } public int BanId { get; set; } public ServerRoleBan Ban { get; set; } = null!; public Guid? UnbanningAdmin { get; set; } public DateTime UnbanTime { get; set; } } [Table("play_time")] public sealed class PlayTime { [Required, Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int Id { get; set; } [Required, ForeignKey("player")] public Guid PlayerId { get; set; } public string Tracker { get; set; } = null!; public TimeSpan TimeSpent { get; set; } } [Table("uploaded_resource_log")] public sealed class UploadedResourceLog { [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int Id { get; set; } public DateTime Date { get; set; } public Guid UserId { get; set; } public string Path { get; set; } = string.Empty; public byte[] Data { get; set; } = default!; } [Index(nameof(PlayerUserId))] public class AdminNote { [Required, Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int Id { get; set; } [ForeignKey("Round")] public int? RoundId { get; set; } public Round? Round { get; set; } [ForeignKey("Player")] public Guid? PlayerUserId { get; set; } public Player? Player { get; set; } [Required] public TimeSpan PlaytimeAtNote { get; set; } [Required, MaxLength(4096)] public string Message { get; set; } = string.Empty; [Required] public NoteSeverity Severity { get; set; } [ForeignKey("CreatedBy")] public Guid? CreatedById { get; set; } public Player? CreatedBy { get; set; } [Required] public DateTime CreatedAt { get; set; } [ForeignKey("LastEditedBy")] public Guid? LastEditedById { get; set; } public Player? LastEditedBy { get; set; } [Required] public DateTime? LastEditedAt { get; set; } public DateTime? ExpirationTime { get; set; } public bool Deleted { get; set; } [ForeignKey("DeletedBy")] public Guid? DeletedById { get; set; } public Player? DeletedBy { get; set; } public DateTime? DeletedAt { get; set; } public bool Secret { get; set; } } [Index(nameof(PlayerUserId))] public class AdminWatchlist { [Required, Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int Id { get; set; } [ForeignKey("Round")] public int? RoundId { get; set; } public Round? Round { get; set; } [ForeignKey("Player")] public Guid? PlayerUserId { get; set; } public Player? Player { get; set; } [Required] public TimeSpan PlaytimeAtNote { get; set; } [Required, MaxLength(4096)] public string Message { get; set; } = string.Empty; [ForeignKey("CreatedBy")] public Guid? CreatedById { get; set; } public Player? CreatedBy { get; set; } [Required] public DateTime CreatedAt { get; set; } [ForeignKey("LastEditedBy")] public Guid? LastEditedById { get; set; } public Player? LastEditedBy { get; set; } [Required] public DateTime? LastEditedAt { get; set; } public DateTime? ExpirationTime { get; set; } public bool Deleted { get; set; } [ForeignKey("DeletedBy")] public Guid? DeletedById { get; set; } public Player? DeletedBy { get; set; } public DateTime? DeletedAt { get; set; } } [Index(nameof(PlayerUserId))] public class AdminMessage { [Required, Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int Id { get; set; } [ForeignKey("Round")] public int? RoundId { get; set; } public Round? Round { get; set; } [ForeignKey("Player")] public Guid? PlayerUserId { get; set; } public Player? Player { get; set; } [Required] public TimeSpan PlaytimeAtNote { get; set; } [Required, MaxLength(4096)] public string Message { get; set; } = string.Empty; [ForeignKey("CreatedBy")] public Guid? CreatedById { get; set; } public Player? CreatedBy { get; set; } [Required] public DateTime CreatedAt { get; set; } [ForeignKey("LastEditedBy")] public Guid? LastEditedById { get; set; } public Player? LastEditedBy { get; set; } public DateTime? LastEditedAt { get; set; } public DateTime? ExpirationTime { get; set; } public bool Deleted { get; set; } [ForeignKey("DeletedBy")] public Guid? DeletedById { get; set; } public Player? DeletedBy { get; set; } public DateTime? DeletedAt { get; set; } /// /// Whether the message has been seen at least once by the player. /// public bool Seen { get; set; } /// /// Whether the message has been dismissed permanently by the player. /// public bool Dismissed { get; set; } } }