feat: dashboard info notes (entity, permission, endpoints, migration)

This commit is contained in:
Jannis committed 2026-10-10 18:47:52 +02:00
1 parent 81ab2af5e3
commit 2473ecd539
10 files changed
+2613 -4

No files matched your search

@@ -6,6 +6,7 @@ using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Common;
using FamilyPlanner.Domain.Dashboard;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Domain.Meals;
using FamilyPlanner.Domain.Tasks;
@@ -28,6 +29,11 @@ public static class DashboardEndpoints
public static IEndpointRouteBuilder MapDashboardEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/dashboard", GetAsync).WithTags("Dashboard").RequirePermission(Permission.DashboardView);
var notes = app.MapGroup("/api/dashboard/notes").WithTags("Dashboard");
notes.MapPost("", CreateNoteAsync).RequirePermission(Permission.DashboardNotesEdit).Validate<SaveDashboardNoteRequest>();
notes.MapPut("/{id:guid}", UpdateNoteAsync).RequirePermission(Permission.DashboardNotesEdit).Validate<SaveDashboardNoteRequest>();
notes.MapDelete("/{id:guid}", DeleteNoteAsync).RequirePermission(Permission.DashboardNotesEdit);
return app;
}
@@ -61,7 +67,58 @@ public static class DashboardEndpoints
var learning = await LoadLearningAsync(user, db, settingsProvider, today, ct);
return TypedResults.Ok(new DashboardDto(today, meals, events, shopping, myTasks, familyTasks, chat, learning));
var notes = await LoadNotesAsync(db, Allowed(Permission.DashboardNotesEdit), ct);
return TypedResults.Ok(new DashboardDto(today, meals, events, shopping, myTasks, familyTasks, chat, learning, notes));
}
private static async Task<DashboardNotesDto> LoadNotesAsync(AppDbContext db, bool canEdit, CancellationToken ct)
{
var notes = await db.DashboardNotes.AsNoTracking().OrderBy(n => n.CreatedAt).ThenBy(n => n.Id).ToListAsync(ct);
return new DashboardNotesDto(notes.Select(ToDto).ToList(), canEdit);
}
private static DashboardNoteDto ToDto(DashboardNote note) => new(note.Id, note.Title, note.Text);
internal static async Task<Results<Created<DashboardNoteDto>, ProblemHttpResult>> CreateNoteAsync(
SaveDashboardNoteRequest request, AppDbContext db, CancellationToken ct)
{
if (await db.DashboardNotes.CountAsync(ct) >= DashboardNote.MaxCount)
{
return EndpointHelpers.Conflict($"Es sind höchstens {DashboardNote.MaxCount} Notizen möglich.");
}
var note = new DashboardNote { Title = request.Title.Trim(), Text = request.Text.Trim() };
db.DashboardNotes.Add(note);
await db.SaveChangesAsync(ct);
return TypedResults.Created($"/api/dashboard/notes/{note.Id}", ToDto(note));
}
internal static async Task<Results<Ok<DashboardNoteDto>, NotFound>> UpdateNoteAsync(
Guid id, SaveDashboardNoteRequest request, AppDbContext db, CancellationToken ct)
{
var note = await db.DashboardNotes.SingleOrDefaultAsync(n => n.Id == id, ct);
if (note is null)
{
return TypedResults.NotFound();
}
note.Title = request.Title.Trim();
note.Text = request.Text.Trim();
await db.SaveChangesAsync(ct);
return TypedResults.Ok(ToDto(note));
}
internal static async Task<NoContent> DeleteNoteAsync(Guid id, AppDbContext db, CancellationToken ct)
{
var note = await db.DashboardNotes.SingleOrDefaultAsync(n => n.Id == id, ct);
if (note is not null)
{
db.DashboardNotes.Remove(note); // Soft Delete über den Interceptor
await db.SaveChangesAsync(ct);
}
return TypedResults.NoContent();
}
private const int LearningTileItems = 5;
@@ -4,6 +4,7 @@ namespace FamilyPlanner.Domain.Authorization;
public enum Permission
{
DashboardView,
DashboardNotesEdit,
ShoppingRead,
ShoppingWrite,
ShoppingDelete,
@@ -55,7 +56,8 @@ public static class PermissionMatrix
private static readonly Dictionary<Permission, AccessLevel[]> Matrix = new()
{
[Permission.DashboardView] = [F, F, F, R],
[Permission.ShoppingRead] = [F, F, F, F],
[Permission.DashboardNotesEdit] = [F, F, N, N], // Info-Kachel pflegen: nur Eltern
[Permission.ShoppingRead] =[F, F, F, F],
[Permission.ShoppingWrite] = [F, F, F, N], // Gast: konfigurierbar
[Permission.ShoppingDelete] = [F, F, R, N], // Kind: nur eigene
[Permission.MealPlanRead] = [F, F, F, F],
@@ -0,0 +1,23 @@
using FamilyPlanner.Domain.Common;
namespace FamilyPlanner.Domain.Dashboard;
/// <summary>Freitext-Info für die Info-Kachel der Startseite (z. B. „Wlan-Passwort“). Wird nur als Text angezeigt.</summary>
public sealed class DashboardNote : AuditableEntity, ISoftDeletable
{
public const int TitleMaxLength = 80;
public const int TextMaxLength = 1000;
/// <summary>Höchstzahl aktiver Notizen, damit die Kachel übersichtlich bleibt.</summary>
public const int MaxCount = 20;
public string Title { get; set; } = string.Empty;
public string Text { get; set; } = string.Empty;
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Guid? DeletedBy { get; set; }
}
@@ -2,6 +2,7 @@ using FamilyPlanner.Domain.Ai;
using FamilyPlanner.Domain.Audit;
using FamilyPlanner.Domain.Calendar;
using FamilyPlanner.Domain.Chat;
using FamilyPlanner.Domain.Dashboard;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Domain.Meals;
using FamilyPlanner.Domain.Push;
@@ -25,6 +26,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<UserAvatar> UserAvatars => Set<UserAvatar>();
public DbSet<DashboardNote> DashboardNotes => Set<DashboardNote>();
public DbSet<ShoppingList> ShoppingLists => Set<ShoppingList>();
public DbSet<ShoppingItem> ShoppingItems => Set<ShoppingItem>();
@@ -0,0 +1,15 @@
using FamilyPlanner.Domain.Dashboard;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace FamilyPlanner.Infrastructure.Persistence.Configurations;
internal sealed class DashboardNoteConfiguration : IEntityTypeConfiguration<DashboardNote>
{
public void Configure(EntityTypeBuilder<DashboardNote> builder)
{
builder.ToTable("DashboardNotes");
builder.Property(n => n.Title).HasMaxLength(DashboardNote.TitleMaxLength).IsRequired();
builder.Property(n => n.Text).HasMaxLength(DashboardNote.TextMaxLength).IsRequired();
}
}
@@ -0,0 +1,47 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase11aDashboardNotes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DashboardNotes",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Title = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
Text = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DashboardNotes", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_DashboardNotes_IsDeleted",
table: "DashboardNotes",
column: "IsDeleted");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DashboardNotes");
}
}
}
@@ -682,6 +682,50 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.ToTable("ChatReactions", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Dashboard.DashboardNote", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Text")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("IsDeleted");
b.ToTable("DashboardNotes", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningAnswer", b =>
{
b.Property<Guid>("Id")
@@ -1,5 +1,7 @@
using FamilyPlanner.Domain.Dashboard;
using FamilyPlanner.Domain.Tasks;
using FamilyPlanner.Shared.Calendar;
using FluentValidation;
namespace FamilyPlanner.Shared.Dashboard;
@@ -45,6 +47,26 @@ public sealed record DashboardLearningItemDto(
/// </summary>
public sealed record DashboardLearningDto(int OpenCount, int ToReviewCount, bool ForParents, IReadOnlyList<DashboardLearningItemDto> Items);
/// <summary>Freitext-Info der Info-Kachel (nur als Text darstellen, nie als HTML).</summary>
public sealed record DashboardNoteDto(Guid Id, string Title, string Text);
/// <summary>Info-Kachel: alle Notizen und ob der Benutzer sie pflegen darf (Eltern).</summary>
public sealed record DashboardNotesDto(IReadOnlyList<DashboardNoteDto> Items, bool CanEdit);
/// <summary>Neue oder geänderte Notiz; die Id kann vom Client stammen.</summary>
public sealed record SaveDashboardNoteRequest(string Title, string Text);
public sealed class SaveDashboardNoteRequestValidator : AbstractValidator<SaveDashboardNoteRequest>
{
public SaveDashboardNoteRequestValidator()
{
RuleFor(r => r.Title).NotEmpty().WithMessage("Bitte einen Titel eingeben.")
.MaximumLength(DashboardNote.TitleMaxLength).WithMessage($"Der Titel darf höchstens {DashboardNote.TitleMaxLength} Zeichen haben.");
RuleFor(r => r.Text).NotEmpty().WithMessage("Bitte einen Text eingeben.")
.MaximumLength(DashboardNote.TextMaxLength).WithMessage($"Der Text darf höchstens {DashboardNote.TextMaxLength} Zeichen haben.");
}
}
/// <summary>
/// Alle Kacheln der Startseite in einem Request. Eine Kachel ist <c>null</c>, wenn die Rolle sie nicht sehen darf
/// (z. B. Gäste ohne Chat, Kinder ohne Familienaufgaben). Die Berechtigungen werden serverseitig geprüft.
@@ -57,4 +79,5 @@ public sealed record DashboardDto(
DashboardMyTasksDto? MyTasks,
DashboardFamilyTasksDto? FamilyTasks,
DashboardChatDto? Chat,
DashboardLearningDto? Learning = null);
DashboardLearningDto? Learning = null,
DashboardNotesDto? Notes = null);
@@ -14,7 +14,8 @@ public sealed class PermissionMatrixTests
public static TheoryData<Permission, AccessLevel, AccessLevel, AccessLevel, AccessLevel> Requirements => new()
{
{ Permission.DashboardView, F, F, F, R }, // Gast: eingeschränkt
{ Permission.ShoppingRead, F, F, F, F },
{ Permission.DashboardNotesEdit, F, F, N, N }, // Info-Kachel der Startseite: nur Eltern pflegen
{ Permission.ShoppingRead,F, F, F, F },
{ Permission.ShoppingWrite, F, F, F, N }, // Gast: nein (konfigurierbar)
{ Permission.ShoppingDelete, F, F, R, N }, // Kind: nur eigene
{ Permission.MealPlanRead, F, F, F, F },