From 929ca9c0a7246c71525d9ab76272b966531ddc76 Mon Sep 17 00:00:00 2001 From: Sarah Faey Date: Thu, 8 Dec 2022 17:37:00 +0100 Subject: [PATCH] Added autodelete for users after one year inactivity --- Lieb/Data/RaidService.cs | 8 +- Lieb/Data/TimerService.cs | 14 + Lieb/Data/UserService.cs | 15 + ...355_AddUserCreatedAtLastSignUp.Designer.cs | 650 ++++++++++++++++++ ...221207211355_AddUserCreatedAtLastSignUp.cs | 37 + Lieb/Migrations/LiebContextModelSnapshot.cs | 6 + Lieb/Models/LiebUser.cs | 2 + 7 files changed, 730 insertions(+), 2 deletions(-) create mode 100644 Lieb/Migrations/20221207211355_AddUserCreatedAtLastSignUp.Designer.cs create mode 100644 Lieb/Migrations/20221207211355_AddUserCreatedAtLastSignUp.cs diff --git a/Lieb/Data/RaidService.cs b/Lieb/Data/RaidService.cs index f53ff89..f73187a 100644 --- a/Lieb/Data/RaidService.cs +++ b/Lieb/Data/RaidService.cs @@ -112,6 +112,9 @@ namespace Lieb.Data return false; } using var context = _contextFactory.CreateDbContext(); + LiebUser user = context.LiebUsers.FirstOrDefault(l => l.Id == liebUserId); + + if(user == null) return false; List signUps = context.RaidSignUps.Where(r => r.RaidId == raidId && r.LiebUserId == liebUserId).ToList(); if (signUpType != SignUpType.Flex && signUps.Where(r => r.SignUpType != SignUpType.Flex).Any()) @@ -122,15 +125,16 @@ namespace Lieb.Data else if (!signUps.Where(r => r.RaidRoleId == plannedRoleId).Any()) { RaidSignUp signUp = new RaidSignUp(raidId, liebUserId, guildWars2AccountId, plannedRoleId, signUpType); - string userName = context.LiebUsers.FirstOrDefault(l => l.Id == liebUserId)?.Name; context.RaidSignUps.Add(signUp); await context.SaveChangesAsync(); - await LogSignUp(signUp, userName, signedUpByUserId); + await LogSignUp(signUp, user.Name, signedUpByUserId); } else { return false; } + user.LastSignUpAt = DateTime.UtcNow; + await context.SaveChangesAsync(); await _discordService.PostRaidMessage(raidId); return true; } diff --git a/Lieb/Data/TimerService.cs b/Lieb/Data/TimerService.cs index 277008b..36da057 100644 --- a/Lieb/Data/TimerService.cs +++ b/Lieb/Data/TimerService.cs @@ -6,6 +6,7 @@ namespace Lieb.Data { private Timer _minuteTimer = null!; private Timer _fiveMinuteTimer = null!; + private Timer _dailyTimer = null!; private IServiceProvider _services; public TimerService(IServiceProvider services) @@ -19,6 +20,8 @@ namespace Lieb.Data TimeSpan.FromMinutes(1)); _fiveMinuteTimer = new Timer(CleanUpRaids, null, TimeSpan.Zero, TimeSpan.FromMinutes(5)); + _dailyTimer = new Timer(CleanUpDatabase, null, TimeSpan.Zero, + TimeSpan.FromDays(1)); return Task.CompletedTask; } @@ -59,6 +62,17 @@ namespace Lieb.Data } } + private async void CleanUpDatabase(object? state) + { + using (var scope = _services.CreateScope()) + { + var userService = + scope.ServiceProvider + .GetRequiredService(); + await userService.DeleteInactiveUsers(); + } + } + public Task StopAsync(CancellationToken stoppingToken) { _minuteTimer?.Change(Timeout.Infinite, 0); diff --git a/Lieb/Data/UserService.cs b/Lieb/Data/UserService.cs index 579c5ee..0c68820 100644 --- a/Lieb/Data/UserService.cs +++ b/Lieb/Data/UserService.cs @@ -318,5 +318,20 @@ namespace Lieb.Data return usableAccounts.First().GuildWars2AccountId; } } + + public async Task DeleteInactiveUsers() + { + using var context = _contextFactory.CreateDbContext(); + List users = context.LiebUsers.ToList(); + + foreach(LiebUser user in users) + { + if((user.LastSignUpAt == null && user.CreatedAt < DateTime.UtcNow.AddYears(-1)) + || (user.LastSignUpAt != null && user.LastSignUpAt < DateTime.UtcNow.AddYears(-1))) + { + await DeleteUser(user.Id); + } + } + } } } diff --git a/Lieb/Migrations/20221207211355_AddUserCreatedAtLastSignUp.Designer.cs b/Lieb/Migrations/20221207211355_AddUserCreatedAtLastSignUp.Designer.cs new file mode 100644 index 0000000..773309e --- /dev/null +++ b/Lieb/Migrations/20221207211355_AddUserCreatedAtLastSignUp.Designer.cs @@ -0,0 +1,650 @@ +// +using System; +using Lieb.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Lieb.Migrations +{ + [DbContext(typeof(LiebContext))] + [Migration("20221207211355_AddUserCreatedAtLastSignUp")] + partial class AddUserCreatedAtLastSignUp + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.10"); + + modelBuilder.Entity("Lieb.Models.DiscordSettings", b => + { + b.Property("DiscordSettingsId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChangeUserNames") + .HasColumnType("INTEGER"); + + b.Property("DiscordLogChannel") + .HasColumnType("INTEGER"); + + b.HasKey("DiscordSettingsId"); + + b.ToTable("DiscordSettings", (string)null); + }); + + modelBuilder.Entity("Lieb.Models.GuildWars2.Equipped", b => + { + b.Property("EquippedId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanTank") + .HasColumnType("INTEGER"); + + b.Property("GuildWars2AccountId") + .HasColumnType("INTEGER"); + + b.Property("GuildWars2BuildId") + .HasColumnType("INTEGER"); + + b.HasKey("EquippedId"); + + b.HasIndex("GuildWars2AccountId"); + + b.HasIndex("GuildWars2BuildId"); + + b.ToTable("Equipped", (string)null); + }); + + modelBuilder.Entity("Lieb.Models.GuildWars2.GuildWars2Account", b => + { + b.Property("GuildWars2AccountId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LiebUserId") + .HasColumnType("INTEGER"); + + b.HasKey("GuildWars2AccountId"); + + b.HasIndex("LiebUserId"); + + b.ToTable("GuildWars2Account", (string)null); + }); + + modelBuilder.Entity("Lieb.Models.GuildWars2.GuildWars2Build", b => + { + b.Property("GuildWars2BuildId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alacrity") + .HasColumnType("INTEGER"); + + b.Property("BuildName") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("TEXT"); + + b.Property("Class") + .HasColumnType("INTEGER"); + + b.Property("DamageType") + .HasColumnType("INTEGER"); + + b.Property("EliteSpecialization") + .HasColumnType("INTEGER"); + + b.Property("Might") + .HasColumnType("INTEGER"); + + b.Property("Quickness") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceLink") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UseInRandomRaid") + .HasColumnType("INTEGER"); + + b.HasKey("GuildWars2BuildId"); + + b.ToTable("GuildWars2Build", (string)null); + }); + + modelBuilder.Entity("Lieb.Models.GuildWars2.Raid.DiscordRaidMessage", b => + { + b.Property("DiscordRaidMessageId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); + + b.Property("DiscordGuildId") + .HasColumnType("INTEGER"); + + b.Property("DiscordMessageId") + .HasColumnType("INTEGER"); + + b.Property("RaidId") + .HasColumnType("INTEGER"); + + b.Property("RaidTemplateId") + .HasColumnType("INTEGER"); + + b.HasKey("DiscordRaidMessageId"); + + b.HasIndex("RaidId"); + + b.HasIndex("RaidTemplateId"); + + b.ToTable("DiscordRaidMessage", (string)null); + }); + + modelBuilder.Entity("Lieb.Models.GuildWars2.Raid.Raid", b => + { + b.Property("RaidId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("EndTimeUTC") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("FreeForAllTimeUTC") + .HasColumnType("TEXT"); + + b.Property("Guild") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MoveFlexUsers") + .HasColumnType("INTEGER"); + + b.Property("Organizer") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RaidOwnerId") + .HasColumnType("INTEGER"); + + b.Property("RaidType") + .HasColumnType("INTEGER"); + + b.Property("RequiredRole") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTimeUTC") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("VoiceChat") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("RaidId"); + + b.ToTable("Raid", (string)null); + }); + + modelBuilder.Entity("Lieb.Models.GuildWars2.Raid.RaidReminder", b => + { + b.Property("RaidReminderId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); + + b.Property("DiscordServerId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("RaidId") + .HasColumnType("INTEGER"); + + b.Property("RaidTemplateId") + .HasColumnType("INTEGER"); + + b.Property("ReminderTimeUTC") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("TimeType") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("RaidReminderId"); + + b.HasIndex("RaidId"); + + b.HasIndex("RaidTemplateId"); + + b.ToTable("RaidReminder", (string)null); + }); + + modelBuilder.Entity("Lieb.Models.GuildWars2.Raid.RaidRole", b => + { + b.Property("RaidRoleId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsRandomSignUpRole") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("RaidId") + .HasColumnType("INTEGER"); + + b.Property("RaidTemplateId") + .HasColumnType("INTEGER"); + + b.Property("Spots") + .HasColumnType("INTEGER"); + + b.HasKey("RaidRoleId"); + + b.HasIndex("RaidId"); + + b.HasIndex("RaidTemplateId"); + + b.ToTable("RaidRole", (string)null); + }); + + modelBuilder.Entity("Lieb.Models.GuildWars2.Raid.RaidSignUp", b => + { + b.Property("RaidSignUpId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalUserName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GuildWars2AccountId") + .HasColumnType("INTEGER"); + + b.Property("LiebUserId") + .HasColumnType("INTEGER"); + + b.Property("RaidId") + .HasColumnType("INTEGER"); + + b.Property("RaidRoleId") + .HasColumnType("INTEGER"); + + b.Property("SignUpType") + .HasColumnType("INTEGER"); + + b.HasKey("RaidSignUpId"); + + b.HasIndex("GuildWars2AccountId"); + + b.HasIndex("LiebUserId"); + + b.HasIndex("RaidId"); + + b.HasIndex("RaidRoleId"); + + b.ToTable("RaidSignUp", (string)null); + }); + + modelBuilder.Entity("Lieb.Models.GuildWars2.Raid.RaidTemplate", b => + { + b.Property("RaidTemplateId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreateDaysBefore") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("FreeForAllTime") + .HasColumnType("TEXT"); + + b.Property("Guild") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("MoveFlexUsers") + .HasColumnType("INTEGER"); + + b.Property("Organizer") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RaidOwnerId") + .HasColumnType("INTEGER"); + + b.Property("RaidType") + .HasColumnType("INTEGER"); + + b.Property("RequiredRole") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("TimeZone") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("VoiceChat") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("RaidTemplateId"); + + b.ToTable("RaidTemplate", (string)null); + }); + + modelBuilder.Entity("Lieb.Models.LiebRole", b => + { + b.Property("LiebRoleId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("LevelToAssign") + .HasColumnType("INTEGER"); + + b.Property("RoleName") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("LiebRoleId"); + + b.ToTable("LiebRole", (string)null); + }); + + modelBuilder.Entity("Lieb.Models.LiebUser", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("AlwaysSignUpWithMainAccount") + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LastSignUpAt") + .HasColumnType("TEXT"); + + b.Property("MainGW2Account") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Pronouns") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LiebUser", (string)null); + }); + + modelBuilder.Entity("Lieb.Models.RoleAssignment", b => + { + b.Property("RoleAssignmentId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LiebRoleId") + .HasColumnType("INTEGER"); + + b.Property("LiebUserId") + .HasColumnType("INTEGER"); + + b.HasKey("RoleAssignmentId"); + + b.HasIndex("LiebRoleId"); + + b.HasIndex("LiebUserId"); + + b.ToTable("RoleAssignment", (string)null); + }); + + modelBuilder.Entity("Lieb.Models.GuildWars2.Equipped", b => + { + b.HasOne("Lieb.Models.GuildWars2.GuildWars2Account", "GuildWars2Account") + .WithMany("EquippedBuilds") + .HasForeignKey("GuildWars2AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Lieb.Models.GuildWars2.GuildWars2Build", "GuildWars2Build") + .WithMany("EquippedRoles") + .HasForeignKey("GuildWars2BuildId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GuildWars2Account"); + + b.Navigation("GuildWars2Build"); + }); + + modelBuilder.Entity("Lieb.Models.GuildWars2.GuildWars2Account", b => + { + b.HasOne("Lieb.Models.LiebUser", null) + .WithMany("GuildWars2Accounts") + .HasForeignKey("LiebUserId"); + }); + + modelBuilder.Entity("Lieb.Models.GuildWars2.Raid.DiscordRaidMessage", b => + { + b.HasOne("Lieb.Models.GuildWars2.Raid.Raid", null) + .WithMany("DiscordRaidMessages") + .HasForeignKey("RaidId"); + + b.HasOne("Lieb.Models.GuildWars2.Raid.RaidTemplate", null) + .WithMany("DiscordRaidMessages") + .HasForeignKey("RaidTemplateId"); + }); + + modelBuilder.Entity("Lieb.Models.GuildWars2.Raid.RaidReminder", b => + { + b.HasOne("Lieb.Models.GuildWars2.Raid.Raid", null) + .WithMany("Reminders") + .HasForeignKey("RaidId"); + + b.HasOne("Lieb.Models.GuildWars2.Raid.RaidTemplate", null) + .WithMany("Reminders") + .HasForeignKey("RaidTemplateId"); + }); + + modelBuilder.Entity("Lieb.Models.GuildWars2.Raid.RaidRole", b => + { + b.HasOne("Lieb.Models.GuildWars2.Raid.Raid", null) + .WithMany("Roles") + .HasForeignKey("RaidId"); + + b.HasOne("Lieb.Models.GuildWars2.Raid.RaidTemplate", null) + .WithMany("Roles") + .HasForeignKey("RaidTemplateId"); + }); + + modelBuilder.Entity("Lieb.Models.GuildWars2.Raid.RaidSignUp", b => + { + b.HasOne("Lieb.Models.GuildWars2.GuildWars2Account", "GuildWars2Account") + .WithMany() + .HasForeignKey("GuildWars2AccountId"); + + b.HasOne("Lieb.Models.LiebUser", "LiebUser") + .WithMany() + .HasForeignKey("LiebUserId"); + + b.HasOne("Lieb.Models.GuildWars2.Raid.Raid", "Raid") + .WithMany("SignUps") + .HasForeignKey("RaidId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Lieb.Models.GuildWars2.Raid.RaidRole", "RaidRole") + .WithMany() + .HasForeignKey("RaidRoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GuildWars2Account"); + + b.Navigation("LiebUser"); + + b.Navigation("Raid"); + + b.Navigation("RaidRole"); + }); + + modelBuilder.Entity("Lieb.Models.RoleAssignment", b => + { + b.HasOne("Lieb.Models.LiebRole", "LiebRole") + .WithMany("RoleAssignments") + .HasForeignKey("LiebRoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Lieb.Models.LiebUser", "LiebUser") + .WithMany("RoleAssignments") + .HasForeignKey("LiebUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LiebRole"); + + b.Navigation("LiebUser"); + }); + + modelBuilder.Entity("Lieb.Models.GuildWars2.GuildWars2Account", b => + { + b.Navigation("EquippedBuilds"); + }); + + modelBuilder.Entity("Lieb.Models.GuildWars2.GuildWars2Build", b => + { + b.Navigation("EquippedRoles"); + }); + + modelBuilder.Entity("Lieb.Models.GuildWars2.Raid.Raid", b => + { + b.Navigation("DiscordRaidMessages"); + + b.Navigation("Reminders"); + + b.Navigation("Roles"); + + b.Navigation("SignUps"); + }); + + modelBuilder.Entity("Lieb.Models.GuildWars2.Raid.RaidTemplate", b => + { + b.Navigation("DiscordRaidMessages"); + + b.Navigation("Reminders"); + + b.Navigation("Roles"); + }); + + modelBuilder.Entity("Lieb.Models.LiebRole", b => + { + b.Navigation("RoleAssignments"); + }); + + modelBuilder.Entity("Lieb.Models.LiebUser", b => + { + b.Navigation("GuildWars2Accounts"); + + b.Navigation("RoleAssignments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Lieb/Migrations/20221207211355_AddUserCreatedAtLastSignUp.cs b/Lieb/Migrations/20221207211355_AddUserCreatedAtLastSignUp.cs new file mode 100644 index 0000000..ab425cd --- /dev/null +++ b/Lieb/Migrations/20221207211355_AddUserCreatedAtLastSignUp.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Lieb.Migrations +{ + public partial class AddUserCreatedAtLastSignUp : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CreatedAt", + table: "LiebUser", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastSignUpAt", + table: "LiebUser", + type: "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CreatedAt", + table: "LiebUser"); + + migrationBuilder.DropColumn( + name: "LastSignUpAt", + table: "LiebUser"); + } + } +} diff --git a/Lieb/Migrations/LiebContextModelSnapshot.cs b/Lieb/Migrations/LiebContextModelSnapshot.cs index 9fb6f48..d93895c 100644 --- a/Lieb/Migrations/LiebContextModelSnapshot.cs +++ b/Lieb/Migrations/LiebContextModelSnapshot.cs @@ -447,6 +447,12 @@ namespace Lieb.Migrations b.Property("Birthday") .HasColumnType("TEXT"); + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LastSignUpAt") + .HasColumnType("TEXT"); + b.Property("MainGW2Account") .HasColumnType("INTEGER"); diff --git a/Lieb/Models/LiebUser.cs b/Lieb/Models/LiebUser.cs index 3d8988e..07ed22e 100644 --- a/Lieb/Models/LiebUser.cs +++ b/Lieb/Models/LiebUser.cs @@ -18,6 +18,8 @@ namespace Lieb.Models public DateTime? Birthday { get; set; } public DateTime? BannedUntil { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? LastSignUpAt { get; set; } public int MainGW2Account { get; set; } public bool AlwaysSignUpWithMainAccount { get; set; } = false; public ICollection GuildWars2Accounts { get; set; } = new List();