feat: dashboard info notes (entity, permission, endpoints, migration)
This commit is contained in:
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>();
|
||||
|
||||
+15
@@ -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();
|
||||
}
|
||||
}
|
||||
+2394
File diff suppressed because it is too large.
Load diff
+47
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user