diff --git a/Client/Modules/SZUAbsolventenverein.Module.PremiumArea/ModuleInfo.cs b/Client/Modules/SZUAbsolventenverein.Module.PremiumArea/ModuleInfo.cs index f393a02..d066570 100644 --- a/Client/Modules/SZUAbsolventenverein.Module.PremiumArea/ModuleInfo.cs +++ b/Client/Modules/SZUAbsolventenverein.Module.PremiumArea/ModuleInfo.cs @@ -9,9 +9,9 @@ namespace SZUAbsolventenverein.Module.PremiumArea { Name = "PremiumArea", Description = "This module adds a premium member system to Octane. Users receive premium status after completing a payment. Premium members get access to exclusive features and content.", - Version = "1.0.0", + Version = "1.0.2", ServerManagerType = "SZUAbsolventenverein.Module.PremiumArea.Manager.PremiumAreaManager, SZUAbsolventenverein.Module.PremiumArea.Server.Oqtane", - ReleaseVersions = "1.0.0", + ReleaseVersions = "1.0.0,1.0.1,1.0.2", Dependencies = "SZUAbsolventenverein.Module.PremiumArea.Shared.Oqtane", PackageName = "SZUAbsolventenverein.Module.PremiumArea" }; diff --git a/Server/Controllers/UserContactController.cs b/Server/Controllers/UserContactController.cs new file mode 100644 index 0000000..e9eaaa3 --- /dev/null +++ b/Server/Controllers/UserContactController.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using System.Collections.Generic; +using Microsoft.AspNetCore.Http; +using Oqtane.Shared; +using Oqtane.Enums; +using Oqtane.Infrastructure; +using SZUAbsolventenverein.Module.PremiumArea.Services; +using Oqtane.Controllers; +using System.Net; +using System.Threading.Tasks; +using Oqtane.Models; + +namespace SZUAbsolventenverein.Module.PremiumArea.Controllers +{ + [Route(ControllerRoutes.ApiRoute)] + public class UserContactController : ModuleControllerBase + { + private readonly IUserContactService _service; + + public UserContactController(IUserContactService service, ILogManager logger, IHttpContextAccessor accessor) : base(logger, accessor) + { + _service = service; + } + + // GET: api//search/query?moduleid=x + [HttpGet("search/{query}")] + [Authorize(Policy = PolicyNames.ViewModule)] + public async Task> Search(string query, string moduleid) + { + int ModuleId; + if (int.TryParse(moduleid, out ModuleId) && IsAuthorizedEntityId(EntityNames.Module, ModuleId)) + { + return await _service.SearchUsersAsync(query, ModuleId); + } + else + { + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + return null; + } + } + + // POST: api//send + [HttpPost("send")] + [Authorize(Policy = PolicyNames.ViewModule)] + public async Task Send(int recipientId, string message, string moduleid) + { + int ModuleId; + if (int.TryParse(moduleid, out ModuleId) && IsAuthorizedEntityId(EntityNames.Module, ModuleId)) + { + await _service.SendMessageAsync(recipientId, message, ModuleId); + } + else + { + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + } + } + } +} diff --git a/Server/Migrations/01000001_AddPremiumTables.cs b/Server/Migrations/01000001_AddPremiumTables.cs new file mode 100644 index 0000000..8aaeea6 --- /dev/null +++ b/Server/Migrations/01000001_AddPremiumTables.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Oqtane.Databases.Interfaces; +using Oqtane.Migrations; +using SZUAbsolventenverein.Module.PremiumArea.Migrations.EntityBuilders; +using SZUAbsolventenverein.Module.PremiumArea.Repository; + +namespace SZUAbsolventenverein.Module.PremiumArea.Migrations +{ + [DbContext(typeof(PremiumAreaContext))] + [Migration("SZUAbsolventenverein.Module.PremiumArea.01.00.00.01")] + public class AddPremiumTables : MultiDatabaseMigration + { + public AddPremiumTables(IDatabase database) : base(database) + { + } + + protected override void Up(MigrationBuilder migrationBuilder) + { + var engAppBuilder = new EngineerApplicationEntityBuilder(migrationBuilder, ActiveDatabase); + engAppBuilder.Create(); + + var userPremBuilder = new UserPremiumEntityBuilder(migrationBuilder, ActiveDatabase); + userPremBuilder.Create(); + + var premEventBuilder = new PremiumEventEntityBuilder(migrationBuilder, ActiveDatabase); + premEventBuilder.Create(); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + var engAppBuilder = new EngineerApplicationEntityBuilder(migrationBuilder, ActiveDatabase); + engAppBuilder.Drop(); + + var userPremBuilder = new UserPremiumEntityBuilder(migrationBuilder, ActiveDatabase); + userPremBuilder.Drop(); + + var premEventBuilder = new PremiumEventEntityBuilder(migrationBuilder, ActiveDatabase); + premEventBuilder.Drop(); + } + } +} diff --git a/Server/Migrations/01000002_AddReportAndFileColumns.cs b/Server/Migrations/01000002_AddReportAndFileColumns.cs new file mode 100644 index 0000000..ddba6c0 --- /dev/null +++ b/Server/Migrations/01000002_AddReportAndFileColumns.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Oqtane.Databases.Interfaces; +using Oqtane.Migrations; +using SZUAbsolventenverein.Module.PremiumArea.Migrations.EntityBuilders; +using SZUAbsolventenverein.Module.PremiumArea.Repository; +using System; + +namespace SZUAbsolventenverein.Module.PremiumArea.Migrations +{ + [DbContext(typeof(PremiumAreaContext))] + [Migration("SZUAbsolventenverein.Module.PremiumArea.01.00.00.02")] + public class AddReportAndFileColumns : MultiDatabaseMigration + { + public AddReportAndFileColumns(IDatabase database) : base(database) + { + } + + protected override void Up(MigrationBuilder migrationBuilder) + { + // Add FileId (nullable int) - assuming missing + migrationBuilder.AddColumn( + name: "FileId", + table: "SZUAbsolventenvereinEngineerApplications", + nullable: true); + + // Add PdfFileName (string 256) + migrationBuilder.AddColumn( + name: "PdfFileName", + table: "SZUAbsolventenvereinEngineerApplications", + maxLength: 256, + nullable: true); + + // Add ApprovedOn (DateTime nullable) - might exist but adding if missing? + // MigrationBuilder will fail if exists. We assume schema drift needs this. + // If it exists, user must handle. + migrationBuilder.AddColumn( + name: "ApprovedOn", + table: "SZUAbsolventenvereinEngineerApplications", + nullable: true); + + // Add IsReported (bool not null default false) + migrationBuilder.AddColumn( + name: "IsReported", + table: "SZUAbsolventenvereinEngineerApplications", + nullable: false, + defaultValue: false); + + // Add ReportReason (string max nullable) + migrationBuilder.AddColumn( + name: "ReportReason", + table: "SZUAbsolventenvereinEngineerApplications", + nullable: true); + + // Add ReportCount (int not null default 0) + migrationBuilder.AddColumn( + name: "ReportCount", + table: "SZUAbsolventenvereinEngineerApplications", + nullable: false, + defaultValue: 0); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsReported", + table: "SZUAbsolventenvereinEngineerApplications"); + + migrationBuilder.DropColumn( + name: "ReportReason", + table: "SZUAbsolventenvereinEngineerApplications"); + + migrationBuilder.DropColumn( + name: "ReportCount", + table: "SZUAbsolventenvereinEngineerApplications"); + + migrationBuilder.DropColumn( + name: "FileId", + table: "SZUAbsolventenvereinEngineerApplications"); + + migrationBuilder.DropColumn( + name: "PdfFileName", + table: "SZUAbsolventenvereinEngineerApplications"); + + migrationBuilder.DropColumn( + name: "ApprovedOn", + table: "SZUAbsolventenvereinEngineerApplications"); + } + } +} diff --git a/Server/Migrations/EntityBuilders/EngineerApplicationEntityBuilder.cs b/Server/Migrations/EntityBuilders/EngineerApplicationEntityBuilder.cs new file mode 100644 index 0000000..de7516d --- /dev/null +++ b/Server/Migrations/EntityBuilders/EngineerApplicationEntityBuilder.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders; +using Oqtane.Databases.Interfaces; +using Oqtane.Migrations; +using Oqtane.Migrations.EntityBuilders; +using SZUAbsolventenverein.Module.PremiumArea.Models; + +namespace SZUAbsolventenverein.Module.PremiumArea.Migrations.EntityBuilders +{ + public class EngineerApplicationEntityBuilder : AuditableBaseEntityBuilder + { + private const string _entityTableName = "SZUAbsolventenvereinEngineerApplications"; + private readonly PrimaryKey _primaryKey = new("PK_SZUAbsolventenvereinEngineerApplications", x => x.ApplicationId); + private readonly ForeignKey _moduleForeignKey = new("FK_SZUAbsolventenvereinEngineerApplications_Module", x => x.ModuleId, "Module", "ModuleId", ReferentialAction.Cascade); + + public EngineerApplicationEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database) + { + EntityTableName = _entityTableName; + PrimaryKey = _primaryKey; + ForeignKeys.Add(_moduleForeignKey); + } + + protected override EngineerApplicationEntityBuilder BuildTable(ColumnsBuilder table) + { + ApplicationId = AddAutoIncrementColumn(table, "ApplicationId"); + UserId = AddIntegerColumn(table, "UserId"); + ModuleId = AddIntegerColumn(table, "ModuleId"); + FileId = AddIntegerColumn(table, "FileId", true); + PdfFileName = AddStringColumn(table, "PdfFileName", 256); + Status = AddStringColumn(table, "Status", 50); + AdminReviewedBy = AddIntegerColumn(table, "AdminReviewedBy", true); + AdminReviewedAt = AddDateTimeColumn(table, "AdminReviewedAt", true); + AdminNote = AddMaxStringColumn(table, "AdminNote"); + SubmittedOn = AddDateTimeColumn(table, "SubmittedOn", true); + ApprovedOn = AddDateTimeColumn(table, "ApprovedOn", true); + IsReported = AddBooleanColumn(table, "IsReported", false); + ReportReason = AddMaxStringColumn(table, "ReportReason", true); + ReportCount = AddIntegerColumn(table, "ReportCount", false); + AddAuditableColumns(table); + return this; + } + + + + public OperationBuilder ApplicationId { get; set; } + public OperationBuilder UserId { get; set; } + public OperationBuilder ModuleId { get; set; } + public OperationBuilder FileId { get; set; } + public OperationBuilder PdfFileName { get; set; } + public OperationBuilder Status { get; set; } + public OperationBuilder AdminReviewedBy { get; set; } + public OperationBuilder AdminReviewedAt { get; set; } + public OperationBuilder AdminNote { get; set; } + public OperationBuilder SubmittedOn { get; set; } + public OperationBuilder ApprovedOn { get; set; } + public OperationBuilder IsReported { get; set; } + public OperationBuilder ReportReason { get; set; } + public OperationBuilder ReportCount { get; set; } + } +} diff --git a/Server/Migrations/EntityBuilders/PremiumEventEntityBuilder.cs b/Server/Migrations/EntityBuilders/PremiumEventEntityBuilder.cs new file mode 100644 index 0000000..863e9e2 --- /dev/null +++ b/Server/Migrations/EntityBuilders/PremiumEventEntityBuilder.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders; +using Oqtane.Databases.Interfaces; +using Oqtane.Migrations; +using Oqtane.Migrations.EntityBuilders; + +namespace SZUAbsolventenverein.Module.PremiumArea.Migrations.EntityBuilders +{ + public class PremiumEventEntityBuilder : AuditableBaseEntityBuilder + { + private const string _entityTableName = "SZUAbsolventenvereinPremiumEvents"; + private readonly PrimaryKey _primaryKey = new("PK_SZUAbsolventenvereinPremiumEvents", x => x.Id); + + public PremiumEventEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database) + { + EntityTableName = _entityTableName; + PrimaryKey = _primaryKey; + } + + protected override PremiumEventEntityBuilder BuildTable(ColumnsBuilder table) + { + Id = AddAutoIncrementColumn(table, "Id"); + UserId = AddIntegerColumn(table, "UserId"); + DeltaDays = AddIntegerColumn(table, "DeltaDays"); + Source = AddStringColumn(table, "Source", 50); + ReferenceId = AddMaxStringColumn(table, "ReferenceId"); + AddAuditableColumns(table); + return this; + } + + public OperationBuilder Id { get; set; } + public OperationBuilder UserId { get; set; } + public OperationBuilder DeltaDays { get; set; } + public OperationBuilder Source { get; set; } + public OperationBuilder ReferenceId { get; set; } + } +} diff --git a/Server/Migrations/EntityBuilders/UserPremiumEntityBuilder.cs b/Server/Migrations/EntityBuilders/UserPremiumEntityBuilder.cs new file mode 100644 index 0000000..0134025 --- /dev/null +++ b/Server/Migrations/EntityBuilders/UserPremiumEntityBuilder.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders; +using Oqtane.Databases.Interfaces; +using Oqtane.Migrations; +using Oqtane.Migrations.EntityBuilders; + +namespace SZUAbsolventenverein.Module.PremiumArea.Migrations.EntityBuilders +{ + public class UserPremiumEntityBuilder : AuditableBaseEntityBuilder + { + private const string _entityTableName = "SZUAbsolventenvereinUserPremium"; + private readonly PrimaryKey _primaryKey = new("PK_SZUAbsolventenvereinUserPremium", x => x.Id); + + public UserPremiumEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database) + { + EntityTableName = _entityTableName; + PrimaryKey = _primaryKey; + } + + protected override UserPremiumEntityBuilder BuildTable(ColumnsBuilder table) + { + Id = AddAutoIncrementColumn(table, "Id"); + UserId = AddIntegerColumn(table, "UserId"); + PremiumUntil = AddDateTimeColumn(table, "PremiumUntil", true); + Source = AddStringColumn(table, "Source", 50); + AddAuditableColumns(table); + return this; + } + + public OperationBuilder Id { get; set; } + public OperationBuilder UserId { get; set; } + public OperationBuilder PremiumUntil { get; set; } + public OperationBuilder Source { get; set; } + } +} diff --git a/Server/Repository/EngineerApplicationRepository.cs b/Server/Repository/EngineerApplicationRepository.cs new file mode 100644 index 0000000..c8a726b --- /dev/null +++ b/Server/Repository/EngineerApplicationRepository.cs @@ -0,0 +1,83 @@ +using Microsoft.EntityFrameworkCore; +using System.Linq; +using System.Collections.Generic; +using Oqtane.Modules; +using SZUAbsolventenverein.Module.PremiumArea.Models; + +namespace SZUAbsolventenverein.Module.PremiumArea.Repository +{ + public interface IEngineerApplicationRepository + { + IEnumerable GetEngineerApplications(int ModuleId); + IEnumerable GetEngineerApplications(int ModuleId, string status); + EngineerApplication GetEngineerApplication(int ApplicationId); + EngineerApplication GetEngineerApplication(int ApplicationId, bool tracking); + EngineerApplication AddEngineerApplication(EngineerApplication EngineerApplication); + EngineerApplication UpdateEngineerApplication(EngineerApplication EngineerApplication); + void DeleteEngineerApplication(int ApplicationId); + } + + public class EngineerApplicationRepository : IEngineerApplicationRepository, ITransientService + { + private readonly IDbContextFactory _factory; + + public EngineerApplicationRepository(IDbContextFactory factory) + { + _factory = factory; + } + + public IEnumerable GetEngineerApplications(int ModuleId) + { + using var db = _factory.CreateDbContext(); + return db.EngineerApplication.Where(item => item.ModuleId == ModuleId).ToList(); + } + + public IEnumerable GetEngineerApplications(int ModuleId, string status) + { + using var db = _factory.CreateDbContext(); + return db.EngineerApplication.Where(item => item.ModuleId == ModuleId && item.Status == status).ToList(); + } + + public EngineerApplication GetEngineerApplication(int ApplicationId) + { + return GetEngineerApplication(ApplicationId, true); + } + + public EngineerApplication GetEngineerApplication(int ApplicationId, bool tracking) + { + using var db = _factory.CreateDbContext(); + if (tracking) + { + return db.EngineerApplication.Find(ApplicationId); + } + else + { + return db.EngineerApplication.AsNoTracking().FirstOrDefault(item => item.ApplicationId == ApplicationId); + } + } + + public EngineerApplication AddEngineerApplication(EngineerApplication EngineerApplication) + { + using var db = _factory.CreateDbContext(); + db.EngineerApplication.Add(EngineerApplication); + db.SaveChanges(); + return EngineerApplication; + } + + public EngineerApplication UpdateEngineerApplication(EngineerApplication EngineerApplication) + { + using var db = _factory.CreateDbContext(); + db.Entry(EngineerApplication).State = EntityState.Modified; + db.SaveChanges(); + return EngineerApplication; + } + + public void DeleteEngineerApplication(int ApplicationId) + { + using var db = _factory.CreateDbContext(); + EngineerApplication EngineerApplication = db.EngineerApplication.Find(ApplicationId); + db.EngineerApplication.Remove(EngineerApplication); + db.SaveChanges(); + } + } +} diff --git a/Server/Repository/PremiumAreaContext.cs b/Server/Repository/PremiumAreaContext.cs index 8ac03c8..4d7b0bb 100644 --- a/Server/Repository/PremiumAreaContext.cs +++ b/Server/Repository/PremiumAreaContext.cs @@ -10,6 +10,9 @@ namespace SZUAbsolventenverein.Module.PremiumArea.Repository public class PremiumAreaContext : DBContextBase, ITransientService, IMultiDatabase { public virtual DbSet PremiumArea { get; set; } + public virtual DbSet EngineerApplication { get; set; } + public virtual DbSet UserPremium { get; set; } + public virtual DbSet PremiumEvent { get; set; } public PremiumAreaContext(IDBContextDependencies DBContextDependencies) : base(DBContextDependencies) { @@ -21,6 +24,9 @@ namespace SZUAbsolventenverein.Module.PremiumArea.Repository base.OnModelCreating(builder); builder.Entity().ToTable(ActiveDatabase.RewriteName("SZUAbsolventenvereinPremiumArea")); + builder.Entity().ToTable(ActiveDatabase.RewriteName("SZUAbsolventenvereinEngineerApplications")); + builder.Entity().ToTable(ActiveDatabase.RewriteName("SZUAbsolventenvereinUserPremium")); + builder.Entity().ToTable(ActiveDatabase.RewriteName("SZUAbsolventenvereinPremiumEvents")); } } } diff --git a/Server/Repository/UserPremiumRepository.cs b/Server/Repository/UserPremiumRepository.cs new file mode 100644 index 0000000..1a58daf --- /dev/null +++ b/Server/Repository/UserPremiumRepository.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore; +using System.Linq; +using System.Collections.Generic; +using Oqtane.Modules; +using SZUAbsolventenverein.Module.PremiumArea.Models; + +namespace SZUAbsolventenverein.Module.PremiumArea.Repository +{ + public interface IUserPremiumRepository + { + UserPremium GetUserPremium(int UserId); + UserPremium SaveUserPremium(UserPremium UserPremium); + void AddPremiumEvent(PremiumEvent premiumEvent); + IEnumerable GetPremiumEvents(int UserId); + } + + public class UserPremiumRepository : IUserPremiumRepository, ITransientService + { + private readonly IDbContextFactory _factory; + + public UserPremiumRepository(IDbContextFactory factory) + { + _factory = factory; + } + + public UserPremium GetUserPremium(int UserId) + { + using var db = _factory.CreateDbContext(); + return db.UserPremium.FirstOrDefault(item => item.UserId == UserId); + } + + public UserPremium SaveUserPremium(UserPremium UserPremium) + { + using var db = _factory.CreateDbContext(); + if (UserPremium.Id > 0) + { + db.Entry(UserPremium).State = EntityState.Modified; + } + else + { + db.UserPremium.Add(UserPremium); + } + db.SaveChanges(); + return UserPremium; + } + + public void AddPremiumEvent(PremiumEvent premiumEvent) + { + using var db = _factory.CreateDbContext(); + db.PremiumEvent.Add(premiumEvent); + db.SaveChanges(); + } + + public IEnumerable GetPremiumEvents(int UserId) + { + using var db = _factory.CreateDbContext(); + return db.PremiumEvent.Where(item => item.UserId == UserId).OrderByDescending(x => x.CreatedOn).ToList(); + } + } +} diff --git a/Shared/Models/EngineerApplication.cs b/Shared/Models/EngineerApplication.cs new file mode 100644 index 0000000..f17468b --- /dev/null +++ b/Shared/Models/EngineerApplication.cs @@ -0,0 +1,37 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Oqtane.Models; + +namespace SZUAbsolventenverein.Module.PremiumArea.Models +{ + [Table("SZUAbsolventenvereinEngineerApplications")] + public class EngineerApplication : ModelBase + { + [Key] + public int ApplicationId { get; set; } + public int UserId { get; set; } + public int ModuleId { get; set; } // Context context + + [Required] + public int? FileId { get; set; } + + [StringLength(256)] + public string PdfFileName { get; set; } + + public bool IsReported { get; set; } + public string ReportReason { get; set; } + public int ReportCount { get; set; } + + // Status: "Draft", "Submitted", "Approved", "Rejected" + [StringLength(50)] + public string Status { get; set; } + + public int? AdminReviewedBy { get; set; } + public DateTime? AdminReviewedAt { get; set; } + public string AdminNote { get; set; } + + public DateTime? SubmittedOn { get; set; } + public DateTime? ApprovedOn { get; set; } + } +} diff --git a/Shared/Models/PremiumEvent.cs b/Shared/Models/PremiumEvent.cs new file mode 100644 index 0000000..df520c3 --- /dev/null +++ b/Shared/Models/PremiumEvent.cs @@ -0,0 +1,23 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Oqtane.Models; + +namespace SZUAbsolventenverein.Module.PremiumArea.Models +{ + [Table("SZUAbsolventenvereinPremiumEvents")] + public class PremiumEvent : ModelBase + { + [Key] + public int Id { get; set; } + + public int UserId { get; set; } + + public int DeltaDays { get; set; } // +365, etc. + + [StringLength(50)] + public string Source { get; set; } + + public string ReferenceId { get; set; } // e.g. "AppId:12" + } +} diff --git a/Shared/Models/UserPremium.cs b/Shared/Models/UserPremium.cs new file mode 100644 index 0000000..12ff3e9 --- /dev/null +++ b/Shared/Models/UserPremium.cs @@ -0,0 +1,21 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Oqtane.Models; + +namespace SZUAbsolventenverein.Module.PremiumArea.Models +{ + [Table("SZUAbsolventenvereinUserPremium")] + public class UserPremium : ModelBase + { + [Key] + public int Id { get; set; } + + public int UserId { get; set; } + + public DateTime? PremiumUntil { get; set; } + + [StringLength(50)] + public string Source { get; set; } // "paid", "promo_engineer_application", "admin" + } +}