From cca2bd81f05e11195109023ae964d7c2513d388b Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:51:55 +0100 Subject: [PATCH] Show a school account's own picture on account rows --- docs/architecture.md | 3 +- docs/setup/configuration.md | 5 +- .../Controllers/AvatarsController.cs | 5 + .../Avatar/GetSchoolAccountAvatarQuery.cs | 33 + src/Schuly.Domain/SchoolUser.cs | 2 + ...13_AddSchoolUserAccountPicture.Designer.cs | 3076 +++++++++++++++++ ...61110134713_AddSchoolUserAccountPicture.cs | 29 + .../SchulyDbContextModelSnapshot.cs | 4 + .../Plugins/LinkedAccountStore.cs | 55 +- src/Schuly.Infrastructure/SchulyDbContext.cs | 1 + .../Services/AccountPictureUrls.cs | 16 +- .../Services/AccountPurger.cs | 1 + .../Services/IAvatarUrlSigner.cs | 5 + .../Services/RetentionSweeper.cs | 1 + src/Schuly.Tests/AccountPictureTests.cs | 110 +- src/Schuly.Tests/RetentionDeletionTests.cs | 4 +- .../TestHelpers/FakeAvatarUrlSigner.cs | 1 + 17 files changed, 3326 insertions(+), 25 deletions(-) create mode 100644 src/Schuly.Application/Queries/Avatar/GetSchoolAccountAvatarQuery.cs create mode 100644 src/Schuly.Infrastructure/Migrations/20261110134713_AddSchoolUserAccountPicture.Designer.cs create mode 100644 src/Schuly.Infrastructure/Migrations/20261110134713_AddSchoolUserAccountPicture.cs diff --git a/docs/architecture.md b/docs/architecture.md index bd377397..b5713936 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,7 +61,8 @@ The backend proxies every byte. Clients never receive S3 URLs or connect to stor - Download from `GET /api/documents/{id}`, which returns a file response. Pictures work the same way. You upload your own with `PUT /api/me/avatar`; plugins store a linked -account's picture through `ILinkedAccounts.SetPictureAsync`. The database holds only a bare blob key, and +account's picture through `ILinkedAccounts.SetPictureAsync` and a school account's own picture through +`ILinkedAccounts.SetSchoolUserPictureAsync`. The database holds only a bare blob key, and the DTOs carry an API path under `/api/avatars/...`. Every avatar route needs the normal sign-in and answers only the picture's owner, so clients load pictures with their bearer token. See [Pictures](setup/configuration.md#pictures). diff --git a/docs/setup/configuration.md b/docs/setup/configuration.md index 0d70b1f3..6afbea62 100644 --- a/docs/setup/configuration.md +++ b/docs/setup/configuration.md @@ -133,13 +133,16 @@ Document and avatar blobs go to an S3-compatible bucket, which is SeaweedFS in t Users upload a profile picture with `PUT /api/me/avatar` (multipart; PNG, JPEG, or WebP; 2 MB at most) and remove it with `DELETE /api/me/avatar`. Plugins store a linked account's picture (the Microsoft -plugin uses the account's own Microsoft photo). The files go to document storage. +plugin uses the account's own Microsoft photo) and a school account's own picture (the Schulnetz plugin +uses the photo on the Lernendenausweis). The files go to document storage. An account row shows the +school account's own picture first, then the linked account's, then the school profile's. | Route | Answers | |---|---| | `GET /api/avatars/users/{applicationUserId}` | The caller's own profile picture. | | `GET /api/avatars/{schoolUserId}` | The picture of one of the caller's school profiles. | | `GET /api/avatars/accounts/{linkedAccountId}` | The picture of one of the caller's linked accounts. | +| `GET /api/avatars/school-accounts/{schoolUserId}` | The own picture of one of the caller's school accounts. | Every route needs the normal sign-in and answers `404` for anyone else's id and for a missing picture. The DTOs (`profilePictureUrl`, `pictureUrl`) carry the path with a `?v=` value that changes with the diff --git a/src/Schuly.API/Controllers/AvatarsController.cs b/src/Schuly.API/Controllers/AvatarsController.cs index 16914e72..9238b438 100644 --- a/src/Schuly.API/Controllers/AvatarsController.cs +++ b/src/Schuly.API/Controllers/AvatarsController.cs @@ -22,6 +22,11 @@ public async Task Get(Guid schoolUserId, CancellationToken ct) => public async Task GetAccount(Guid linkedAccountId, CancellationToken ct) => Picture(await mediator.Send(new GetLinkedAccountAvatarQuery(linkedAccountId), ct)); + /// The own picture of one of the caller's school accounts (e.g. the student ID card photo). + [HttpGet("school-accounts/{schoolUserId:guid}")] + public async Task GetSchoolAccount(Guid schoolUserId, CancellationToken ct) => + Picture(await mediator.Send(new GetSchoolAccountAvatarQuery(schoolUserId), ct)); + /// The caller's own profile picture. [HttpGet("users/{applicationUserId:guid}")] public async Task GetUser(Guid applicationUserId, CancellationToken ct) => diff --git a/src/Schuly.Application/Queries/Avatar/GetSchoolAccountAvatarQuery.cs b/src/Schuly.Application/Queries/Avatar/GetSchoolAccountAvatarQuery.cs new file mode 100644 index 00000000..c18718d3 --- /dev/null +++ b/src/Schuly.Application/Queries/Avatar/GetSchoolAccountAvatarQuery.cs @@ -0,0 +1,33 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Schuly.Application.Authorization; +using Schuly.Application.Models; +using Schuly.Infrastructure; +using Schuly.Infrastructure.Services; +using Schuly.Infrastructure.Storage; + +namespace Schuly.Application.Queries.Avatar +{ + /// The own picture of one of the caller's school accounts; anyone else's is "not found". + [AllowAuthenticated] + public record GetSchoolAccountAvatarQuery(Guid SchoolUserId) : IQuery>; + + public class GetSchoolAccountAvatarQueryHandler(SchulyDbContext db, IDocumentStorage storage, IUserService userService) : IQueryHandler> + { + public async ValueTask> Handle(GetSchoolAccountAvatarQuery query, CancellationToken ct) + { + var userId = await userService.GetCurrentUserIdAsync(ct); + var key = await db.SchoolUsers + .AsNoTracking() + .Where(su => su.Id == query.SchoolUserId && su.ApplicationUserId == userId) + .Select(su => su.AccountPictureKey) + .SingleOrDefaultAsync(ct); + + if (!BlobKeys.IsStored(key)) + return Result.Failure("No stored picture for this school account"); + + var stream = await storage.OpenReadAsync(key!, ct); + return Result.Success(new AvatarDownload(stream)); + } + } +} diff --git a/src/Schuly.Domain/SchoolUser.cs b/src/Schuly.Domain/SchoolUser.cs index 5dfb1b80..ac288d25 100644 --- a/src/Schuly.Domain/SchoolUser.cs +++ b/src/Schuly.Domain/SchoolUser.cs @@ -15,6 +15,8 @@ public class SchoolUser : Base public required string LastName { get; set; } public required string Email { get; set; } public string? ProfilePictureUrl { get; set; } + /// Blob key of the account's own picture set by its plugin (e.g. the student ID card photo); shown only on the owner's account rows. + public string? AccountPictureKey { get; set; } public required DateOnly EntryDate { get; set; } diff --git a/src/Schuly.Infrastructure/Migrations/20261110134713_AddSchoolUserAccountPicture.Designer.cs b/src/Schuly.Infrastructure/Migrations/20261110134713_AddSchoolUserAccountPicture.Designer.cs new file mode 100644 index 00000000..a8cb24f1 --- /dev/null +++ b/src/Schuly.Infrastructure/Migrations/20261110134713_AddSchoolUserAccountPicture.Designer.cs @@ -0,0 +1,3076 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Schuly.Infrastructure; + +#nullable disable + +namespace Schuly.Infrastructure.Migrations +{ + [DbContext(typeof(SchulyDbContext))] + [Migration("20261110134713_AddSchoolUserAccountPicture")] + partial class AddSchoolUserAccountPicture + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ClassSchoolUser", b => + { + b.Property("ClassesId") + .HasColumnType("uuid"); + + b.Property("StudentsId") + .HasColumnType("uuid"); + + b.HasKey("ClassesId", "StudentsId"); + + b.HasIndex("StudentsId"); + + b.ToTable("ClassSchoolUser"); + }); + + modelBuilder.Entity("ClassTeacher", b => + { + b.Property("ClassesId") + .HasColumnType("uuid"); + + b.Property("TeachersId") + .HasColumnType("uuid"); + + b.HasKey("ClassesId", "TeachersId"); + + b.HasIndex("TeachersId"); + + b.ToTable("ClassTeacher"); + }); + + modelBuilder.Entity("Schuly.Domain.Absence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndTime") + .HasColumnType("text"); + + b.Property("ExcuseDeadline") + .HasColumnType("date"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("From") + .HasColumnType("timestamp with time zone"); + + b.Property("IsExcused") + .HasColumnType("boolean"); + + b.Property("Lessons") + .HasColumnType("integer"); + + b.Property("ParentStatus") + .HasColumnType("text"); + + b.Property("ParentStatusDate") + .HasColumnType("date"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("SchoolUserId") + .HasColumnType("uuid"); + + b.Property("Source") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StartTime") + .HasColumnType("text"); + + b.Property("TrainerComment") + .HasColumnType("text"); + + b.Property("TrainerStatus") + .HasColumnType("text"); + + b.Property("TrainerStatusDate") + .HasColumnType("date"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Until") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SchoolUserId", "Source", "ExternalId") + .IsUnique() + .HasFilter("\"Source\" IS NOT NULL"); + + b.HasIndex("SchoolUserId", "From", "Until", "Type"); + + b.ToTable("Absences", t => + { + t.HasCheckConstraint("CK_Absence_FromBeforeUntil", "\"From\" <= \"Until\""); + }); + }); + + modelBuilder.Entity("Schuly.Domain.AgendaEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClassId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryType") + .HasColumnType("integer"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Place") + .HasColumnType("text"); + + b.Property("SchoolId") + .HasColumnType("uuid"); + + b.Property("SchoolUserId") + .HasColumnType("uuid"); + + b.Property("Source") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ClassId", "Date"); + + b.HasIndex("SchoolId", "Date"); + + b.HasIndex("SchoolUserId", "Date"); + + b.HasIndex("SchoolUserId", "Source", "ExternalId") + .IsUnique() + .HasFilter("\"Source\" IS NOT NULL"); + + b.ToTable("AgendaEntries", t => + { + t.HasCheckConstraint("CK_AgendaEntry_EndDateAfterDate", "\"EndDate\" IS NULL OR \"EndDate\" >= \"Date\""); + + t.HasCheckConstraint("CK_AgendaEntry_ExactlyOneScope", "(CASE WHEN \"ClassId\" IS NULL THEN 0 ELSE 1 END + CASE WHEN \"SchoolId\" IS NULL THEN 0 ELSE 1 END + CASE WHEN \"SchoolUserId\" IS NULL THEN 0 ELSE 1 END) = 1"); + }); + }); + + modelBuilder.Entity("Schuly.Domain.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PolicyAcceptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PolicyVersionAccepted") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProfilePictureUrl") + .HasColumnType("text"); + + b.Property("SwissSchoolConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("ExternalId") + .IsUnique(); + + b.ToTable("ApplicationUsers"); + }); + + modelBuilder.Entity("Schuly.Domain.Class", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SchoolId") + .HasColumnType("uuid"); + + b.Property("Source") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SchoolId", "Name") + .IsUnique(); + + b.ToTable("Classes"); + }); + + modelBuilder.Entity("Schuly.Domain.DeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("Schuly.Domain.Exam", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClassId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReportedClassAverage") + .HasColumnType("numeric"); + + b.Property("Source") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ClassId", "Date"); + + b.ToTable("Exams"); + }); + + modelBuilder.Entity("Schuly.Domain.Grade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExamId") + .HasColumnType("uuid"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("SchoolUserId") + .HasColumnType("uuid"); + + b.Property("Score") + .HasPrecision(4, 2) + .HasColumnType("numeric(4,2)"); + + b.Property("Source") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Weighting") + .HasPrecision(4, 2) + .HasColumnType("numeric(4,2)"); + + b.HasKey("Id"); + + b.HasIndex("ExamId"); + + b.HasIndex("SchoolUserId", "ExamId"); + + b.HasIndex("SchoolUserId", "Source", "ExternalId") + .IsUnique() + .HasFilter("\"Source\" IS NOT NULL"); + + b.ToTable("Grades"); + }); + + modelBuilder.Entity("Schuly.Domain.Helply.HelplyAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationUserId") + .HasColumnType("uuid"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("FromAssistant") + .HasColumnType("boolean"); + + b.Property("HasText") + .HasColumnType("boolean"); + + b.Property("MediaType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("PageCount") + .HasColumnType("integer"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("TextLength") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId"); + + b.HasIndex("ConversationId", "MessageId"); + + b.HasIndex("MessageId", "CreatedAt"); + + b.ToTable("HelplyAttachments"); + }); + + modelBuilder.Entity("Schuly.Domain.Helply.HelplyAttachmentContent", b => + { + b.Property("AttachmentId") + .HasColumnType("uuid"); + + b.Property("Data") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Text") + .HasColumnType("bytea"); + + b.HasKey("AttachmentId"); + + b.ToTable("HelplyAttachmentContents"); + }); + + modelBuilder.Entity("Schuly.Domain.Helply.HelplyConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastActivityAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Session") + .HasColumnType("bytea"); + + b.Property("Title") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Transcript") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("ApplicationUserId", "LastActivityAt"); + + b.ToTable("HelplyConversations"); + }); + + modelBuilder.Entity("Schuly.Domain.Helply.HelplySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkspaceEnabled") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId") + .IsUnique(); + + b.ToTable("HelplySettings"); + }); + + modelBuilder.Entity("Schuly.Domain.InactivityWarning", b => + { + b.Property("LoginId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DeleteAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("LoginId"); + + b.ToTable("InactivityWarnings"); + }); + + modelBuilder.Entity("Schuly.Domain.LinkedAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PictureKey") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId", "Provider", "ExternalId") + .IsUnique(); + + b.ToTable("LinkedAccounts"); + }); + + modelBuilder.Entity("Schuly.Domain.Mcp.McpActivity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Allowed") + .HasColumnType("boolean"); + + b.Property("ApplicationUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("McpApiKeyId") + .HasColumnType("uuid"); + + b.Property("McpGrantId") + .HasColumnType("uuid"); + + b.Property("ToolName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("McpApiKeyId", "CreatedAt"); + + b.HasIndex("McpGrantId", "CreatedAt"); + + b.ToTable("McpActivities"); + }); + + modelBuilder.Entity("Schuly.Domain.Mcp.McpApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("McpApiKeys"); + }); + + modelBuilder.Entity("Schuly.Domain.Mcp.McpAuthorizationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CodeChallenge") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("McpGrantId") + .HasColumnType("uuid"); + + b.Property("RedirectUri") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Resource") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CodeHash") + .IsUnique(); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("McpGrantId"); + + b.ToTable("McpAuthorizationCodes"); + }); + + modelBuilder.Entity("Schuly.Domain.Mcp.McpGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("McpOAuthClientId") + .HasColumnType("uuid"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("McpOAuthClientId"); + + b.HasIndex("ApplicationUserId", "McpOAuthClientId"); + + b.ToTable("McpGrants"); + }); + + modelBuilder.Entity("Schuly.Domain.Mcp.McpOAuthClient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientUri") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("RedirectUris") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("RegistrationIpHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SoftwareId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SoftwareVersion") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.HasIndex("RegistrationIpHash", "CreatedAt"); + + b.ToTable("McpOAuthClients"); + }); + + modelBuilder.Entity("Schuly.Domain.Mcp.McpToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationUserId") + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("McpGrantId") + .HasColumnType("uuid"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("Resource") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("McpGrantId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("McpTokens"); + }); + + modelBuilder.Entity("Schuly.Domain.NotificationOutbox", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("NextAttemptAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OccursAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SubjectName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Summary") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("NotificationOutbox"); + }); + + modelBuilder.Entity("Schuly.Domain.NotificationPreference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Absences") + .HasColumnType("boolean"); + + b.Property("Agenda") + .HasColumnType("boolean"); + + b.Property("ApplicationUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Grades") + .HasColumnType("boolean"); + + b.Property("IncludeDetails") + .HasColumnType("boolean"); + + b.Property("IncludeGradeValue") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId") + .IsUnique(); + + b.ToTable("NotificationPreferences"); + }); + + modelBuilder.Entity("Schuly.Domain.PendingPurge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("PluginSource") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PendingPurges"); + }); + + modelBuilder.Entity("Schuly.Domain.PluginTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Cron") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastDurationMs") + .HasColumnType("bigint"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("LastFinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("NextRunAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PluginName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TaskName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TotalFailures") + .HasColumnType("integer"); + + b.Property("TotalRuns") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PluginName", "TaskName") + .IsUnique(); + + b.ToTable("PluginTasks"); + }); + + modelBuilder.Entity("Schuly.Domain.School", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Country") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("LogoUrl") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("State") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Street") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Website") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Zip") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Schools"); + }); + + modelBuilder.Entity("Schuly.Domain.SchoolSystem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LoginMethod") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LoginMethodPath") + .HasColumnType("text"); + + b.Property("LogoUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("PluginBasePath") + .HasColumnType("text"); + + b.Property("PrivateAuthStrategy") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("text"); + + b.Property("StatelessBasePath") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("SchoolSystems"); + }); + + modelBuilder.Entity("Schuly.Domain.SchoolUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountPictureKey") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("ApplicationUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("EntryDate") + .HasColumnType("date"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LeaveDate") + .HasColumnType("date"); + + b.Property("ProfilePictureUrl") + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("SchoolId") + .HasColumnType("uuid"); + + b.Property("Source") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId", "SchoolId"); + + b.HasIndex("SchoolId", "Role"); + + b.HasIndex("ApplicationUserId", "SchoolId", "Email") + .IsUnique(); + + b.ToTable("SchoolUsers"); + }); + + modelBuilder.Entity("Schuly.Domain.SecurityAuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Detail") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("UserId"); + + b.ToTable("SecurityAuditEntries"); + }); + + modelBuilder.Entity("Schuly.Domain.SemesterReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClassName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeficiencyPoints") + .HasColumnType("integer"); + + b.Property("ExcusedAbsences") + .HasColumnType("integer"); + + b.Property("GradeAverage") + .HasPrecision(4, 2) + .HasColumnType("numeric(4,2)"); + + b.Property("InsufficientGradeCount") + .HasColumnType("integer"); + + b.Property("ProgramCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PromotionDecision") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SchoolUserId") + .HasColumnType("uuid"); + + b.Property("SchoolYearStart") + .HasColumnType("integer"); + + b.Property("SemesterHalf") + .HasColumnType("integer"); + + b.Property("Source") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TotalAbsences") + .HasColumnType("integer"); + + b.Property("UnexcusedAbsences") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SchoolUserId", "ProgramCode", "SchoolYearStart", "SemesterHalf") + .IsUnique(); + + b.ToTable("SemesterReports", t => + { + t.HasCheckConstraint("CK_SemesterReport_SemesterHalf", "\"SemesterHalf\" IN (1, 2)"); + }); + }); + + modelBuilder.Entity("Schuly.Domain.SemesterSubjectGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Grade") + .HasPrecision(4, 2) + .HasColumnType("numeric(4,2)"); + + b.Property("Marker") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SemesterReportId") + .HasColumnType("uuid"); + + b.Property("SubjectCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SubjectName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SubjectTypeMarker") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SemesterReportId", "SubjectCode") + .IsUnique(); + + b.ToTable("SemesterSubjectGrades"); + }); + + modelBuilder.Entity("Schuly.Domain.StudentDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("CompletedDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnteredBy") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("FileUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FollowUpAction") + .HasColumnType("text"); + + b.Property("FollowUpDate") + .HasColumnType("date"); + + b.Property("NotifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SchoolUserId") + .HasColumnType("uuid"); + + b.Property("Source") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SchoolUserId", "Source", "ExternalId") + .IsUnique() + .HasFilter("\"Source\" IS NOT NULL"); + + b.ToTable("StudentDocuments"); + }); + + modelBuilder.Entity("Schuly.Domain.Teacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationUserId") + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SchoolId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId"); + + b.HasIndex("SchoolId", "Code") + .IsUnique(); + + b.ToTable("Teachers"); + }); + + modelBuilder.Entity("Schuly.Domain.VaultEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Ciphertext") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("KeyVersion") + .HasColumnType("integer"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("PluginName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Tag") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PluginName", "Key") + .IsUnique(); + + b.ToTable("VaultEntries"); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaEmailVerificationChallenge", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("bigint"); + + b.Property("NormalizedEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SecurityStamp") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("ToamaisutaaEmailVerificationChallenges", (string)null); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaEmailVerificationCode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConsumedAt") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("bigint"); + + b.Property("FailedAttempts") + .HasColumnType("integer"); + + b.Property("NormalizedEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ToamaisutaaEmailVerificationCodes", (string)null); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaEmailVerificationToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("bigint"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpiresAt") + .HasColumnType("bigint"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("ToamaisutaaEmailVerificationTokens", (string)null); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaExternalLogin", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("bigint"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("Issuer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("LastSignInAt") + .HasColumnType("bigint"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ProviderKey", "Subject") + .IsUnique(); + + b.ToTable("ToamaisutaaExternalLogins", (string)null); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaExternalLoginRequest", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientCodeChallenge") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ClientState") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("CodeVerifier") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ConsumedAt") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("bigint"); + + b.Property("ExchangeCodeHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExchangeConsumedAt") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("bigint"); + + b.Property("LinkUserId") + .HasColumnType("uuid"); + + b.Property("Nonce") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RedirectUri") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ReturnUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecurityStamp") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StateHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExchangeCodeHash") + .IsUnique(); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("LinkUserId"); + + b.HasIndex("StateHash") + .IsUnique(); + + b.ToTable("ToamaisutaaExternalLoginRequests", (string)null); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaInvitationToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("bigint"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpiresAt") + .HasColumnType("bigint"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("ToamaisutaaInvitationTokens", (string)null); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaMagicLinkToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("bigint"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("ToamaisutaaMagicLinkTokens", (string)null); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaPasskeyChallenge", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Ceremony") + .HasColumnType("integer"); + + b.Property("ConsumedAt") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("bigint"); + + b.Property("Options") + .IsRequired() + .HasColumnType("text"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("ToamaisutaaPasskeyChallenges", (string)null); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaPasskeyCredential", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("AttestationFormat") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("bigint"); + + b.Property("CredentialId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("bytea"); + + b.Property("IsBackedUp") + .HasColumnType("boolean"); + + b.Property("IsBackupEligible") + .HasColumnType("boolean"); + + b.Property("Label") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastUsedAt") + .HasColumnType("bigint"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("bytea"); + + b.Property("SignCount") + .HasColumnType("bigint"); + + b.Property("Transports") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CredentialId") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("ToamaisutaaPasskeyCredentials", (string)null); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaPasswordCredential", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("bigint"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmedAt") + .HasColumnType("bigint"); + + b.Property("FailedAttemptCount") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.Property("FirstFailedAttemptAt") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("LockedOutUntil") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("LockoutStrikes") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("NormalizedEmail") + .IsConcurrencyToken() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UpdatedAt") + .HasColumnType("bigint"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("UserId"); + + b.HasIndex("NormalizedEmail") + .IsUnique(); + + b.HasIndex("NormalizedUserName") + .IsUnique(); + + b.ToTable("ToamaisutaaPasswordCredentials", (string)null); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaPasswordResetToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("bigint"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("ToamaisutaaPasswordResetTokens", (string)null); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaRecoveryCode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConsumedAt") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("bigint"); + + b.Property("HashVersion") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CodeHash"); + + b.ToTable("ToamaisutaaRecoveryCodes", (string)null); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaRefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AuthenticationMethods") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("bigint"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("FamilyStartedAt") + .HasColumnType("bigint"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastUsedAt") + .HasColumnType("bigint"); + + b.Property("RevokedAt") + .HasColumnType("bigint"); + + b.Property("RevokedReason") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RotatedAt") + .HasColumnType("bigint"); + + b.Property("SecondFactorAt") + .HasColumnType("bigint"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TwoFactorSource") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserAgent") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("ToamaisutaaRefreshTokens", (string)null); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaSignInThrottle", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Source") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FailedAttemptCount") + .HasColumnType("integer"); + + b.Property("FirstFailedAttemptAt") + .HasColumnType("bigint"); + + b.Property("LockedOutUntil") + .HasColumnType("bigint"); + + b.HasKey("UserId", "Source"); + + b.ToTable("ToamaisutaaSignInThrottles", (string)null); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaTrustedDevice", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("bigint"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("FamilyStartedAt") + .HasColumnType("bigint"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastUsedAt") + .HasColumnType("bigint"); + + b.Property("RevokedAt") + .HasColumnType("bigint"); + + b.Property("RevokedReason") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RotatedAt") + .HasColumnType("bigint"); + + b.Property("SecondFactorAt") + .HasColumnType("bigint"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserAgent") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("ToamaisutaaTrustedDevices", (string)null); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaTwoFactorChallenge", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AuthenticationMethods") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ConsumedAt") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("bigint"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("SecurityStamp") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("ToamaisutaaTwoFactorChallenges", (string)null); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("bigint"); + + b.Property("DisabledAt") + .HasColumnType("bigint"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("bigint"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.ToTable("ToamaisutaaUsers", (string)null); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaUserTwoFactor", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("ConfirmedAt") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("bigint"); + + b.Property("EncryptionKeyVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FailedAttemptCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("FirstFailedAttemptAt") + .HasColumnType("bigint"); + + b.Property("LastUsedStep") + .HasColumnType("bigint"); + + b.Property("LockedOutUntil") + .HasColumnType("bigint"); + + b.Property("LockoutStrikes") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("SecretCiphertext") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("bytea"); + + b.Property("SecretNonce") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea"); + + b.Property("SecretTag") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea"); + + b.Property("UpdatedAt") + .HasColumnType("bigint"); + + b.HasKey("UserId"); + + b.ToTable("ToamaisutaaUserTwoFactors", (string)null); + }); + + modelBuilder.Entity("ClassSchoolUser", b => + { + b.HasOne("Schuly.Domain.Class", null) + .WithMany() + .HasForeignKey("ClassesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Schuly.Domain.SchoolUser", null) + .WithMany() + .HasForeignKey("StudentsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ClassTeacher", b => + { + b.HasOne("Schuly.Domain.Class", null) + .WithMany() + .HasForeignKey("ClassesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Schuly.Domain.Teacher", null) + .WithMany() + .HasForeignKey("TeachersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Schuly.Domain.Absence", b => + { + b.HasOne("Schuly.Domain.SchoolUser", "SchoolUser") + .WithMany("Absences") + .HasForeignKey("SchoolUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SchoolUser"); + }); + + modelBuilder.Entity("Schuly.Domain.AgendaEntry", b => + { + b.HasOne("Schuly.Domain.Class", "Class") + .WithMany("Agenda") + .HasForeignKey("ClassId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Schuly.Domain.School", "School") + .WithMany("Agenda") + .HasForeignKey("SchoolId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Schuly.Domain.SchoolUser", "SchoolUser") + .WithMany("Agenda") + .HasForeignKey("SchoolUserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Class"); + + b.Navigation("School"); + + b.Navigation("SchoolUser"); + }); + + modelBuilder.Entity("Schuly.Domain.Class", b => + { + b.HasOne("Schuly.Domain.School", "School") + .WithMany("Classes") + .HasForeignKey("SchoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("School"); + }); + + modelBuilder.Entity("Schuly.Domain.DeviceToken", b => + { + b.HasOne("Schuly.Domain.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApplicationUser"); + }); + + modelBuilder.Entity("Schuly.Domain.Exam", b => + { + b.HasOne("Schuly.Domain.Class", "Class") + .WithMany("Exams") + .HasForeignKey("ClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Class"); + }); + + modelBuilder.Entity("Schuly.Domain.Grade", b => + { + b.HasOne("Schuly.Domain.Exam", "Exam") + .WithMany("Grades") + .HasForeignKey("ExamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Schuly.Domain.SchoolUser", "SchoolUser") + .WithMany("Grades") + .HasForeignKey("SchoolUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Exam"); + + b.Navigation("SchoolUser"); + }); + + modelBuilder.Entity("Schuly.Domain.Helply.HelplyAttachment", b => + { + b.HasOne("Schuly.Domain.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Schuly.Domain.Helply.HelplyConversation", "Conversation") + .WithMany() + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApplicationUser"); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("Schuly.Domain.Helply.HelplyAttachmentContent", b => + { + b.HasOne("Schuly.Domain.Helply.HelplyAttachment", null) + .WithOne("Content") + .HasForeignKey("Schuly.Domain.Helply.HelplyAttachmentContent", "AttachmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Schuly.Domain.Helply.HelplyConversation", b => + { + b.HasOne("Schuly.Domain.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApplicationUser"); + }); + + modelBuilder.Entity("Schuly.Domain.Helply.HelplySettings", b => + { + b.HasOne("Schuly.Domain.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApplicationUser"); + }); + + modelBuilder.Entity("Schuly.Domain.LinkedAccount", b => + { + b.HasOne("Schuly.Domain.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApplicationUser"); + }); + + modelBuilder.Entity("Schuly.Domain.Mcp.McpActivity", b => + { + b.HasOne("Schuly.Domain.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApplicationUser"); + }); + + modelBuilder.Entity("Schuly.Domain.Mcp.McpApiKey", b => + { + b.HasOne("Schuly.Domain.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApplicationUser"); + }); + + modelBuilder.Entity("Schuly.Domain.Mcp.McpAuthorizationCode", b => + { + b.HasOne("Schuly.Domain.Mcp.McpGrant", "McpGrant") + .WithMany() + .HasForeignKey("McpGrantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("McpGrant"); + }); + + modelBuilder.Entity("Schuly.Domain.Mcp.McpGrant", b => + { + b.HasOne("Schuly.Domain.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Schuly.Domain.Mcp.McpOAuthClient", "McpOAuthClient") + .WithMany() + .HasForeignKey("McpOAuthClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApplicationUser"); + + b.Navigation("McpOAuthClient"); + }); + + modelBuilder.Entity("Schuly.Domain.Mcp.McpToken", b => + { + b.HasOne("Schuly.Domain.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Schuly.Domain.Mcp.McpGrant", "McpGrant") + .WithMany() + .HasForeignKey("McpGrantId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("ApplicationUser"); + + b.Navigation("McpGrant"); + }); + + modelBuilder.Entity("Schuly.Domain.NotificationPreference", b => + { + b.HasOne("Schuly.Domain.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApplicationUser"); + }); + + modelBuilder.Entity("Schuly.Domain.SchoolSystem", b => + { + b.OwnsMany("Schuly.Domain.SchoolSystemLoginField", "LoginFields", b1 => + { + b1.Property("SchoolSystemId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("DefaultValue"); + + b1.Property("Key") + .IsRequired(); + + b1.Property("Label") + .IsRequired(); + + b1.Property("Placeholder"); + + b1.Property("ReplacedByLinkedAccount"); + + b1.Property("Required"); + + b1.Property("Type") + .IsRequired(); + + b1.HasKey("SchoolSystemId", "__synthesizedOrdinal"); + + b1.ToTable("SchoolSystems"); + + b1 + .ToJson("LoginFields") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("SchoolSystemId"); + + b1.OwnsMany("Schuly.Domain.SchoolSystemLoginFieldSuggestion", "Suggestions", b2 => + { + b2.Property("SchoolSystemLoginFieldSchoolSystemId"); + + b2.Property("SchoolSystemLoginField__synthesizedOrdinal"); + + b2.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b2.Property("Label") + .IsRequired(); + + b2.Property("Value") + .IsRequired(); + + b2.HasKey("SchoolSystemLoginFieldSchoolSystemId", "SchoolSystemLoginField__synthesizedOrdinal", "__synthesizedOrdinal"); + + b2.ToTable("SchoolSystems"); + + b2.WithOwner() + .HasForeignKey("SchoolSystemLoginFieldSchoolSystemId", "SchoolSystemLoginField__synthesizedOrdinal"); + }); + + b1.Navigation("Suggestions"); + }); + + b.Navigation("LoginFields"); + }); + + modelBuilder.Entity("Schuly.Domain.SchoolUser", b => + { + b.HasOne("Schuly.Domain.ApplicationUser", "ApplicationUser") + .WithMany("SchoolUsers") + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Schuly.Domain.School", "School") + .WithMany("SchoolUsers") + .HasForeignKey("SchoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApplicationUser"); + + b.Navigation("School"); + }); + + modelBuilder.Entity("Schuly.Domain.SemesterReport", b => + { + b.HasOne("Schuly.Domain.SchoolUser", "SchoolUser") + .WithMany("SemesterReports") + .HasForeignKey("SchoolUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SchoolUser"); + }); + + modelBuilder.Entity("Schuly.Domain.SemesterSubjectGrade", b => + { + b.HasOne("Schuly.Domain.SemesterReport", "SemesterReport") + .WithMany("Subjects") + .HasForeignKey("SemesterReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SemesterReport"); + }); + + modelBuilder.Entity("Schuly.Domain.StudentDocument", b => + { + b.HasOne("Schuly.Domain.SchoolUser", "SchoolUser") + .WithMany("Documents") + .HasForeignKey("SchoolUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SchoolUser"); + }); + + modelBuilder.Entity("Schuly.Domain.Teacher", b => + { + b.HasOne("Schuly.Domain.ApplicationUser", "ApplicationUser") + .WithMany("Teachers") + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Schuly.Domain.School", "School") + .WithMany("Teachers") + .HasForeignKey("SchoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApplicationUser"); + + b.Navigation("School"); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaEmailVerificationChallenge", b => + { + b.HasOne("Toamaisutaa.Abstractions.ToamaisutaaUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaEmailVerificationCode", b => + { + b.HasOne("Toamaisutaa.Abstractions.ToamaisutaaUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaEmailVerificationToken", b => + { + b.HasOne("Toamaisutaa.Abstractions.ToamaisutaaUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaExternalLogin", b => + { + b.HasOne("Toamaisutaa.Abstractions.ToamaisutaaUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaExternalLoginRequest", b => + { + b.HasOne("Toamaisutaa.Abstractions.ToamaisutaaUser", null) + .WithMany() + .HasForeignKey("LinkUserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaInvitationToken", b => + { + b.HasOne("Toamaisutaa.Abstractions.ToamaisutaaUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaMagicLinkToken", b => + { + b.HasOne("Toamaisutaa.Abstractions.ToamaisutaaUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaPasskeyChallenge", b => + { + b.HasOne("Toamaisutaa.Abstractions.ToamaisutaaUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaPasskeyCredential", b => + { + b.HasOne("Toamaisutaa.Abstractions.ToamaisutaaUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaPasswordCredential", b => + { + b.HasOne("Toamaisutaa.Abstractions.ToamaisutaaUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaPasswordResetToken", b => + { + b.HasOne("Toamaisutaa.Abstractions.ToamaisutaaUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaRecoveryCode", b => + { + b.HasOne("Toamaisutaa.Abstractions.ToamaisutaaUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaRefreshToken", b => + { + b.HasOne("Toamaisutaa.Abstractions.ToamaisutaaUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaSignInThrottle", b => + { + b.HasOne("Toamaisutaa.Abstractions.ToamaisutaaUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaTrustedDevice", b => + { + b.HasOne("Toamaisutaa.Abstractions.ToamaisutaaUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaTwoFactorChallenge", b => + { + b.HasOne("Toamaisutaa.Abstractions.ToamaisutaaUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Toamaisutaa.Abstractions.ToamaisutaaUserTwoFactor", b => + { + b.HasOne("Toamaisutaa.Abstractions.ToamaisutaaUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Schuly.Domain.ApplicationUser", b => + { + b.Navigation("SchoolUsers"); + + b.Navigation("Teachers"); + }); + + modelBuilder.Entity("Schuly.Domain.Class", b => + { + b.Navigation("Agenda"); + + b.Navigation("Exams"); + }); + + modelBuilder.Entity("Schuly.Domain.Exam", b => + { + b.Navigation("Grades"); + }); + + modelBuilder.Entity("Schuly.Domain.Helply.HelplyAttachment", b => + { + b.Navigation("Content"); + }); + + modelBuilder.Entity("Schuly.Domain.School", b => + { + b.Navigation("Agenda"); + + b.Navigation("Classes"); + + b.Navigation("SchoolUsers"); + + b.Navigation("Teachers"); + }); + + modelBuilder.Entity("Schuly.Domain.SchoolUser", b => + { + b.Navigation("Absences"); + + b.Navigation("Agenda"); + + b.Navigation("Documents"); + + b.Navigation("Grades"); + + b.Navigation("SemesterReports"); + }); + + modelBuilder.Entity("Schuly.Domain.SemesterReport", b => + { + b.Navigation("Subjects"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Schuly.Infrastructure/Migrations/20261110134713_AddSchoolUserAccountPicture.cs b/src/Schuly.Infrastructure/Migrations/20261110134713_AddSchoolUserAccountPicture.cs new file mode 100644 index 00000000..6e6347aa --- /dev/null +++ b/src/Schuly.Infrastructure/Migrations/20261110134713_AddSchoolUserAccountPicture.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Schuly.Infrastructure.Migrations +{ + /// + public partial class AddSchoolUserAccountPicture : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AccountPictureKey", + table: "SchoolUsers", + type: "character varying(300)", + maxLength: 300, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AccountPictureKey", + table: "SchoolUsers"); + } + } +} diff --git a/src/Schuly.Infrastructure/Migrations/SchulyDbContextModelSnapshot.cs b/src/Schuly.Infrastructure/Migrations/SchulyDbContextModelSnapshot.cs index d9363635..28d572b3 100644 --- a/src/Schuly.Infrastructure/Migrations/SchulyDbContextModelSnapshot.cs +++ b/src/Schuly.Infrastructure/Migrations/SchulyDbContextModelSnapshot.cs @@ -1250,6 +1250,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("AccountPictureKey") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + b.Property("ApplicationUserId") .HasColumnType("uuid"); diff --git a/src/Schuly.Infrastructure/Plugins/LinkedAccountStore.cs b/src/Schuly.Infrastructure/Plugins/LinkedAccountStore.cs index 87f9ee1f..04bd35c3 100644 --- a/src/Schuly.Infrastructure/Plugins/LinkedAccountStore.cs +++ b/src/Schuly.Infrastructure/Plugins/LinkedAccountStore.cs @@ -105,27 +105,54 @@ public async Task SetPictureAsync(Guid applicationUserId, Guid linkedAccou return false; var previous = account.PictureKey; - if (content is null) - { - account.PictureKey = null; - } - else - { - // The declared type is ignored: the bytes decide, so nothing but a real picture is ever served from here. - var bytes = await ReadAtMostAsync(content, MaxPictureBytes, cancellationToken); - if (bytes is null || PictureType(bytes) is not { } type) - return false; - - var blob = await storage.UploadAsync(new MemoryStream(bytes, writable: false), $"account-picture.{type.Extension}", type.ContentType, cancellationToken); - account.PictureKey = blob.Key; - } + if (await StorePictureAsync(content, cancellationToken) is not { } picture) + return false; + account.PictureKey = picture.Key; account.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(cancellationToken); await DeletePictureAsync(previous, cancellationToken); return true; } + /// The school account's own picture (), only on the user's own school users. + public async Task SetSchoolUserPictureAsync(Guid applicationUserId, Guid schoolUserId, Stream? content, string? contentType, CancellationToken cancellationToken = default) + { + if (storage is null) + return false; + + var schoolUser = await db.SchoolUsers.FirstOrDefaultAsync(su => su.Id == schoolUserId && su.ApplicationUserId == applicationUserId, cancellationToken); + if (schoolUser is null) + return false; + + var previous = schoolUser.AccountPictureKey; + if (await StorePictureAsync(content, cancellationToken) is not { } picture) + return false; + + schoolUser.AccountPictureKey = picture.Key; + schoolUser.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(cancellationToken); + await DeletePictureAsync(previous, cancellationToken); + return true; + } + + // Uploads the picture and returns its key (null key for null content), or null when it is no picture. + private async Task StorePictureAsync(Stream? content, CancellationToken cancellationToken) + { + if (content is null) + return new StoredPicture(null); + + // The declared type is ignored: the bytes decide, so nothing but a real picture is ever served from here. + var bytes = await ReadAtMostAsync(content, MaxPictureBytes, cancellationToken); + if (bytes is null || PictureType(bytes) is not { } type) + return null; + + var blob = await storage!.UploadAsync(new MemoryStream(bytes, writable: false), $"account-picture.{type.Extension}", type.ContentType, cancellationToken); + return new StoredPicture(blob.Key); + } + + private sealed record StoredPicture(string? Key); + /// The PNG, JPEG or WebP type of , or null for anything else. public static (string ContentType, string Extension)? PictureType(ReadOnlySpan data) { diff --git a/src/Schuly.Infrastructure/SchulyDbContext.cs b/src/Schuly.Infrastructure/SchulyDbContext.cs index 6e4ed0c8..726b51ad 100644 --- a/src/Schuly.Infrastructure/SchulyDbContext.cs +++ b/src/Schuly.Infrastructure/SchulyDbContext.cs @@ -136,6 +136,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(su => su.FirstName).HasMaxLength(100); entity.Property(su => su.LastName).HasMaxLength(100); entity.Property(su => su.Source).HasMaxLength(SourceMaxLength); + entity.Property(su => su.AccountPictureKey).HasMaxLength(300); entity.Property(su => su.Role).IsRequired(); entity.HasOne(su => su.ApplicationUser) diff --git a/src/Schuly.Infrastructure/Services/AccountPictureUrls.cs b/src/Schuly.Infrastructure/Services/AccountPictureUrls.cs index 7c2d5fd7..e13f5b4a 100644 --- a/src/Schuly.Infrastructure/Services/AccountPictureUrls.cs +++ b/src/Schuly.Infrastructure/Services/AccountPictureUrls.cs @@ -13,8 +13,8 @@ public async Task ResolveAsync(Guid applicationUserId, IRe var students = schoolUserIds.Count == 0 ? [] : await db.SchoolUsers.AsNoTracking() - .Where(su => su.ApplicationUserId == applicationUserId && schoolUserIds.Contains(su.Id) && su.ProfilePictureUrl != null) - .Select(su => new { su.Id, su.ProfilePictureUrl }) + .Where(su => su.ApplicationUserId == applicationUserId && schoolUserIds.Contains(su.Id) && (su.AccountPictureKey != null || su.ProfilePictureUrl != null)) + .Select(su => new { su.Id, su.AccountPictureKey, su.ProfilePictureUrl }) .ToListAsync(ct); var accounts = linkedAccountIds.Count == 0 @@ -25,16 +25,18 @@ public async Task ResolveAsync(Guid applicationUserId, IRe .ToListAsync(ct); return new AccountPictureLookup( - students.Select(s => (s.Id, Url: signer.ToPublicUrl(s.Id, s.ProfilePictureUrl))).Where(s => s.Url is not null).ToDictionary(s => s.Id, s => s.Url!), - accounts.Select(a => (a.Id, Url: signer.ToPublicAccountUrl(a.Id, a.PictureKey))).Where(a => a.Url is not null).ToDictionary(a => a.Id, a => a.Url!)); + students.Select(s => (s.Id, Url: signer.ToPublicSchoolAccountUrl(s.Id, s.AccountPictureKey))).Where(s => s.Url is not null).ToDictionary(s => s.Id, s => s.Url!), + accounts.Select(a => (a.Id, Url: signer.ToPublicAccountUrl(a.Id, a.PictureKey))).Where(a => a.Url is not null).ToDictionary(a => a.Id, a => a.Url!), + students.Select(s => (s.Id, Url: signer.ToPublicUrl(s.Id, s.ProfilePictureUrl))).Where(s => s.Url is not null).ToDictionary(s => s.Id, s => s.Url!)); } } - public sealed record AccountPictureLookup(IReadOnlyDictionary SchoolUsers, IReadOnlyDictionary LinkedAccounts) + public sealed record AccountPictureLookup(IReadOnlyDictionary SchoolAccounts, IReadOnlyDictionary LinkedAccounts, IReadOnlyDictionary SchoolUsers) { - /// The linked account's own picture wins over the student profile's. + /// The school account's own picture, then the linked account's, then the student profile's. public string? For(Guid? schoolUserId, Guid? linkedAccountId) => - linkedAccountId is { } a && LinkedAccounts.TryGetValue(a, out var account) ? account + schoolUserId is { } own && SchoolAccounts.TryGetValue(own, out var schoolAccount) ? schoolAccount + : linkedAccountId is { } a && LinkedAccounts.TryGetValue(a, out var account) ? account : schoolUserId is { } s && SchoolUsers.TryGetValue(s, out var student) ? student : null; } diff --git a/src/Schuly.Infrastructure/Services/AccountPurger.cs b/src/Schuly.Infrastructure/Services/AccountPurger.cs index 0b3160cc..74db6849 100644 --- a/src/Schuly.Infrastructure/Services/AccountPurger.cs +++ b/src/Schuly.Infrastructure/Services/AccountPurger.cs @@ -202,6 +202,7 @@ public async Task RetryPendingAsync(CancellationToken cancel schoolUser.Classes.Clear(); dbContext.SchoolUsers.RemoveRange(schoolUsers); fileUrls.AddRange(schoolUsers.Where(su => BlobKeys.IsStored(su.ProfilePictureUrl)).Select(su => su.ProfilePictureUrl!)); + fileUrls.AddRange(schoolUsers.Where(su => BlobKeys.IsStored(su.AccountPictureKey)).Select(su => su.AccountPictureKey!)); return (schoolUsers.Count, documents.Count, fileUrls); } diff --git a/src/Schuly.Infrastructure/Services/IAvatarUrlSigner.cs b/src/Schuly.Infrastructure/Services/IAvatarUrlSigner.cs index 3f611bc7..126b9931 100644 --- a/src/Schuly.Infrastructure/Services/IAvatarUrlSigner.cs +++ b/src/Schuly.Infrastructure/Services/IAvatarUrlSigner.cs @@ -17,6 +17,9 @@ public interface IAvatarUrlSigner /// A linked account's picture. string? ToPublicAccountUrl(Guid linkedAccountId, string? stored); + + /// A school account's own picture (SchoolUser.AccountPictureKey). + string? ToPublicSchoolAccountUrl(Guid schoolUserId, string? stored); } public class AvatarUrlSigner : IAvatarUrlSigner @@ -27,6 +30,8 @@ public class AvatarUrlSigner : IAvatarUrlSigner public string? ToPublicAccountUrl(Guid linkedAccountId, string? stored) => Build(linkedAccountId, stored, "/api/avatars/accounts", passExternal: false); + public string? ToPublicSchoolAccountUrl(Guid schoolUserId, string? stored) => Build(schoolUserId, stored, "/api/avatars/school-accounts", passExternal: false); + private static string? Build(Guid id, string? stored, string route, bool passExternal) { if (string.IsNullOrWhiteSpace(stored)) return null; diff --git a/src/Schuly.Infrastructure/Services/RetentionSweeper.cs b/src/Schuly.Infrastructure/Services/RetentionSweeper.cs index 260fcd35..b6853a63 100644 --- a/src/Schuly.Infrastructure/Services/RetentionSweeper.cs +++ b/src/Schuly.Infrastructure/Services/RetentionSweeper.cs @@ -299,6 +299,7 @@ private async Task SweepOrphanedBlobsAsync(CancellationToken cancellationTo referenced.UnionWith(await dbContext.StudentDocuments.Where(d => d.FileUrl != null).Select(d => d.FileUrl!).ToListAsync(cancellationToken)); referenced.UnionWith(await dbContext.ApplicationUsers.Where(u => u.ProfilePictureUrl != null).Select(u => u.ProfilePictureUrl!).ToListAsync(cancellationToken)); referenced.UnionWith(await dbContext.SchoolUsers.Where(su => su.ProfilePictureUrl != null).Select(su => su.ProfilePictureUrl!).ToListAsync(cancellationToken)); + referenced.UnionWith(await dbContext.SchoolUsers.Where(su => su.AccountPictureKey != null).Select(su => su.AccountPictureKey!).ToListAsync(cancellationToken)); referenced.UnionWith(await dbContext.LinkedAccounts.Where(a => a.PictureKey != null).Select(a => a.PictureKey!).ToListAsync(cancellationToken)); // With no references at all this is a fresh or wrong database, not a bucket full of garbage. diff --git a/src/Schuly.Tests/AccountPictureTests.cs b/src/Schuly.Tests/AccountPictureTests.cs index 6141e235..700549ae 100644 --- a/src/Schuly.Tests/AccountPictureTests.cs +++ b/src/Schuly.Tests/AccountPictureTests.cs @@ -161,7 +161,7 @@ public async Task Avatar_routes_answer_401_without_a_sign_in() await using var host = await LinkingTestHost.StartAsync(); var id = Guid.NewGuid(); - foreach (var path in new[] { $"/api/avatars/{id}", $"/api/avatars/users/{id}", $"/api/avatars/accounts/{id}", $"/api/avatars/{id}?exp=9999999999&sig=abc" }) + foreach (var path in new[] { $"/api/avatars/{id}", $"/api/avatars/users/{id}", $"/api/avatars/accounts/{id}", $"/api/avatars/school-accounts/{id}", $"/api/avatars/{id}?exp=9999999999&sig=abc" }) await Assert.That((await host.Send(HttpMethod.Get, path, null)).StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); } @@ -211,6 +211,114 @@ public async Task The_accounts_list_shows_only_the_owners_own_pictures() await Assert.That(pictures["plain"]).IsNull(); } + private static async Task NewStudentAsync(SchulyDbContext ctx, Guid owner) + { + var student = TestDb.NewSchoolUser(Guid.NewGuid()); + student.ApplicationUserId = owner; + ctx.SchoolUsers.Add(student); + await ctx.SaveChangesAsync(); + return student; + } + + [Test] + public async Task A_school_account_picture_is_stored_replaced_and_cleared_only_by_its_owner() + { + using var ctx = TestDb.NewContext(nameof(A_school_account_picture_is_stored_replaced_and_cleared_only_by_its_owner)); + var (store, storage) = NewStore(ctx); + var owner = NewUser(ctx); + var other = NewUser(ctx); + var student = await NewStudentAsync(ctx, owner); + + await Assert.That(await store.SetSchoolUserPictureAsync(other, student.Id, new MemoryStream(Png), "image/png")).IsFalse(); + await Assert.That(await store.SetSchoolUserPictureAsync(owner, Guid.NewGuid(), new MemoryStream(Png), "image/png")).IsFalse(); + await Assert.That(await store.SetSchoolUserPictureAsync(owner, student.Id, new MemoryStream(""u8.ToArray()), "image/png")).IsFalse(); + await Assert.That(await new LinkedAccountStore(ctx, TestVault.Factory()).SetSchoolUserPictureAsync(owner, student.Id, new MemoryStream(Png), "image/png")).IsFalse(); + await Assert.That(storage.Objects.Count).IsEqualTo(0); + + await Assert.That(await store.SetSchoolUserPictureAsync(owner, student.Id, new MemoryStream(Jpeg), "image/png")).IsTrue(); + var first = ctx.SchoolUsers.Single().AccountPictureKey!; + await Assert.That(storage.ContentTypes[first]).IsEqualTo("image/jpeg"); + + await Assert.That(await store.SetSchoolUserPictureAsync(owner, student.Id, new MemoryStream(Png), null)).IsTrue(); + var second = ctx.SchoolUsers.Single().AccountPictureKey!; + await Assert.That(second).IsNotEqualTo(first); + await Assert.That(storage.Objects.ContainsKey(first)).IsFalse(); + await Assert.That(storage.Objects.Count).IsEqualTo(1); + + await Assert.That(await store.SetSchoolUserPictureAsync(other, student.Id, null, null)).IsFalse(); + await Assert.That(ctx.SchoolUsers.Single().AccountPictureKey).IsEqualTo(second); + await Assert.That(await store.SetSchoolUserPictureAsync(owner, student.Id, null, null)).IsTrue(); + await Assert.That(ctx.SchoolUsers.Single().AccountPictureKey).IsNull(); + await Assert.That(storage.Objects.Count).IsEqualTo(0); + } + + [Test] + public async Task A_school_account_picture_is_served_only_to_its_owner() + { + using var ctx = TestDb.NewContext(nameof(A_school_account_picture_is_served_only_to_its_owner)); + var (store, storage) = NewStore(ctx); + var owner = NewUser(ctx); + var other = NewUser(ctx); + var student = await NewStudentAsync(ctx, owner); + var bare = await NewStudentAsync(ctx, owner); + await store.SetSchoolUserPictureAsync(owner, student.Id, new MemoryStream(Png), "image/png"); + + async Task As(Guid caller, Guid schoolUserId) => + (await new GetSchoolAccountAvatarQueryHandler(ctx, storage, new FakeUserService { CurrentUserId = caller }).Handle(new GetSchoolAccountAvatarQuery(schoolUserId), default)).IsSuccess; + + await Assert.That(await As(owner, student.Id)).IsTrue(); + await Assert.That(await As(other, student.Id)).IsFalse(); + await Assert.That(await As(owner, bare.Id)).IsFalse(); + + var urls = new AvatarUrlSigner(); + await Assert.That(urls.ToPublicSchoolAccountUrl(student.Id, "https://example.com/a.png")).IsNull(); + await Assert.That(urls.ToPublicSchoolAccountUrl(student.Id, ctx.SchoolUsers.Single(su => su.Id == student.Id).AccountPictureKey)!).StartsWith($"/api/avatars/school-accounts/{student.Id}?v="); + } + + [Test] + public async Task A_row_shows_the_school_account_picture_then_the_linked_account_then_the_profile() + { + using var ctx = TestDb.NewContext(nameof(A_row_shows_the_school_account_picture_then_the_linked_account_then_the_profile)); + var owner = NewUser(ctx); + var other = NewUser(ctx); + var withCard = await NewStudentAsync(ctx, owner); + withCard.AccountPictureKey = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/account-picture.jpg"; + withCard.ProfilePictureUrl = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/avatar.png"; + var profileOnly = await NewStudentAsync(ctx, owner); + profileOnly.ProfilePictureUrl = "cccccccccccccccccccccccccccccccc/avatar.png"; + var foreign = await NewStudentAsync(ctx, other); + foreign.AccountPictureKey = "dddddddddddddddddddddddddddddddd/account-picture.jpg"; + var microsoft = new Domain.LinkedAccount { Id = Guid.NewGuid(), ApplicationUserId = owner, Provider = LinkedProviders.Microsoft, ExternalId = "oid", Email = "a@school.ch", PictureKey = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee/account-picture.jpg" }; + ctx.LinkedAccounts.Add(microsoft); + await ctx.SaveChangesAsync(); + + var pictures = await new AccountPictureUrls(ctx, new FakeAvatarUrlSigner()).ResolveAsync(owner, [withCard.Id, profileOnly.Id, foreign.Id], [microsoft.Id], default); + + await Assert.That(pictures.For(withCard.Id, microsoft.Id)).IsEqualTo($"/api/avatars/school-accounts/{withCard.Id}"); + await Assert.That(pictures.For(withCard.Id, null)).IsEqualTo($"/api/avatars/school-accounts/{withCard.Id}"); + await Assert.That(pictures.For(profileOnly.Id, microsoft.Id)).IsEqualTo($"/api/avatars/accounts/{microsoft.Id}"); + await Assert.That(pictures.For(profileOnly.Id, null)).IsEqualTo(profileOnly.ProfilePictureUrl); + await Assert.That(pictures.For(foreign.Id, null)).IsNull(); + await Assert.That(pictures.For(foreign.Id, microsoft.Id)).IsEqualTo($"/api/avatars/accounts/{microsoft.Id}"); + } + + [Test] + public async Task Purging_the_user_deletes_the_school_account_pictures() + { + using var ctx = TestDb.NewContext(nameof(Purging_the_user_deletes_the_school_account_pictures)); + var (store, storage) = NewStore(ctx); + var user = NewUser(ctx); + var student = await NewStudentAsync(ctx, user); + await store.SetSchoolUserPictureAsync(user, student.Id, new MemoryStream(Png), "image/png"); + await Assert.That(storage.Objects.Count).IsEqualTo(1); + + var purger = new AccountPurger(ctx, storage, TestVault.Factory(), NullPluginUserDataPurger.Instance, NullLogger.Instance); + await purger.PurgeApplicationUserAsync(user); + + await Assert.That(storage.Objects.Count).IsEqualTo(0); + await Assert.That(ctx.SchoolUsers.Count()).IsEqualTo(0); + } + [Test] public async Task Purging_the_user_deletes_the_linked_account_pictures() { diff --git a/src/Schuly.Tests/RetentionDeletionTests.cs b/src/Schuly.Tests/RetentionDeletionTests.cs index 3057989a..3e6bdc2c 100644 --- a/src/Schuly.Tests/RetentionDeletionTests.cs +++ b/src/Schuly.Tests/RetentionDeletionTests.cs @@ -326,12 +326,14 @@ public async Task Only_old_unreferenced_blobs_with_our_key_shape_are_deleted() using var env = new Env(); var (user, _) = await env.AccountAsync(DateTime.UtcNow); var keep = $"{Guid.NewGuid():N}/avatar.png"; + var keepCard = $"{Guid.NewGuid():N}/account-picture.jpg"; var orphan = $"{Guid.NewGuid():N}/avatar.png"; var young = $"{Guid.NewGuid():N}/doc.pdf"; user.ProfilePictureUrl = keep; + (await env.SchoolUserAsync(user.Id, "schulware", null)).AccountPictureKey = keepCard; await env.Db.SaveChangesAsync(); var old = DateTime.UtcNow.AddDays(-30); - env.Storage.Stored.AddRange([new StoredBlob(keep, old), new StoredBlob(orphan, old), new StoredBlob(young, DateTime.UtcNow), new StoredBlob("someone-elses/file.txt", old)]); + env.Storage.Stored.AddRange([new StoredBlob(keep, old), new StoredBlob(keepCard, old), new StoredBlob(orphan, old), new StoredBlob(young, DateTime.UtcNow), new StoredBlob("someone-elses/file.txt", old)]); var result = await env.SweepAsync(); diff --git a/src/Schuly.Tests/TestHelpers/FakeAvatarUrlSigner.cs b/src/Schuly.Tests/TestHelpers/FakeAvatarUrlSigner.cs index e5c96e3a..38fb676c 100644 --- a/src/Schuly.Tests/TestHelpers/FakeAvatarUrlSigner.cs +++ b/src/Schuly.Tests/TestHelpers/FakeAvatarUrlSigner.cs @@ -7,5 +7,6 @@ public sealed class FakeAvatarUrlSigner : IAvatarUrlSigner public string? ToPublicUrl(Guid schoolUserId, string? stored) => stored; public string? ToPublicUserUrl(Guid applicationUserId, string? stored) => stored; public string? ToPublicAccountUrl(Guid linkedAccountId, string? stored) => stored is null ? null : $"/api/avatars/accounts/{linkedAccountId}"; + public string? ToPublicSchoolAccountUrl(Guid schoolUserId, string? stored) => stored is null ? null : $"/api/avatars/school-accounts/{schoolUserId}"; } }