feat: add learning domain, grading rules, permissions and Phase10aLearning migration

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 13:28:21 +02:00
1 parent e1cb189c54
commit 5f54d10223
14 files changed
+4426

No files matched your search

@@ -39,6 +39,8 @@ public static class AuditActions
public const string AiProviderCreated = "ai.provider_created";
public const string AiProviderUpdated = "ai.provider_updated";
public const string AiProviderDeleted = "ai.provider_deleted";
public const string LearningPointsCredited = "learning.points_credited";
public const string LearningPointsReversed = "learning.points_reversed";
public static string DisplayName(string action) => action switch
{
@@ -50,6 +52,8 @@ public static class AuditActions
AiProviderCreated => "KI-Anbieter angelegt",
AiProviderUpdated => "KI-Anbieter geändert",
AiProviderDeleted => "KI-Anbieter gelöscht",
LearningPointsCredited => "Lernpunkte gutgeschrieben",
LearningPointsReversed => "Lernpunkte zurückgebucht",
UserCreated => "Benutzer angelegt",
UserUpdated => "Benutzer geändert",
UserDeleted => "Benutzer gelöscht",
@@ -19,6 +19,8 @@ public enum Permission
FamilyChat,
DirectMessages,
AiUse,
LearningManage,
LearningSolve,
UserManagement,
CalendarManagement,
SettingsManagement,
@@ -68,6 +70,8 @@ public static class PermissionMatrix
[Permission.FamilyChat] = [F, F, F, N], // Gast: nur wenn freigegeben
[Permission.DirectMessages] = [F, F, R, N], // Kind: nur an Familienmitglieder
[Permission.AiUse] = [F, F, N, N], // KI-Funktionen nutzen: nur Eltern (ADR-0020)
[Permission.LearningManage] = [F, F, N, N], // Übungsblätter anlegen/zuweisen/prüfen (ADR-0021)
[Permission.LearningSolve] = [F, F, R, N], // Kind: nur eigene Blätter lösen (ADR-0021)
[Permission.UserManagement] = [F, N, N, N],
[Permission.CalendarManagement] = [F, N, N, N],
[Permission.SettingsManagement] = [F, N, N, N],
@@ -0,0 +1,279 @@
using FamilyPlanner.Domain.Common;
namespace FamilyPlanner.Domain.Learning;
/// <summary>Status eines Übungsblatts. Nach dem Zuweisen wird er aus den Versuchen abgeleitet (<see cref="LearningRules.DeriveStatus"/>).</summary>
public enum LearningSetStatus
{
Draft = 0,
Assigned = 1,
InProgress = 2,
Submitted = 3,
Reviewed = 4,
}
/// <summary><see cref="Practice"/>: Sofort-Rückmeldung und Hinweise. <see cref="Exam"/>: Prüfungsregeln (ADR-0021 Punkt 13).</summary>
public enum LearningMode
{
Practice = 0,
Exam = 1,
}
/// <summary>Herkunft eines Blatts. <see cref="Ai"/> wird erst in Phase 10b gesetzt; die Felder dafür gibt es schon.</summary>
public enum LearningSource
{
Manual = 0,
Ai = 1,
}
/// <summary><see cref="Paper"/> (Foto/Papierlösung) ist nur als Wert vorgesehen und wird erst in Phase 10c freigeschaltet.</summary>
public enum LearningAnswerType
{
Number = 0,
Text = 1,
SingleChoice = 2,
Paper = 3,
}
/// <summary><see cref="Open"/>: noch nicht bewertet, wartet auf die Elternprüfung.</summary>
public enum LearningVerdict
{
Open = 0,
Correct = 1,
Partial = 2,
Wrong = 3,
}
public enum LearningGrader
{
Auto = 0,
Ai = 1,
Parent = 2,
}
public enum LearningAttemptStatus
{
InProgress = 0,
Submitted = 1,
Reviewed = 2,
}
/// <summary>Die Fächer des ersten Schnitts. Fach ist ein Text aus dieser Liste – weitere Fächer brauchen keine Modelländerung.</summary>
public static class LearningSubjects
{
public const string Math = "Mathe";
public const string German = "Deutsch";
public static IReadOnlyList<string> All { get; } = [Math, German];
public static bool IsValid(string? subject) => subject is not null && All.Contains(subject, StringComparer.Ordinal);
}
/// <summary>Ein Übungsblatt (ADR-0021). Eigene Entität, kein Teil der Familienaufgaben (ADR-0014).</summary>
public sealed class LearningSet : AuditableEntity, ISoftDeletable, IHasRowVersion
{
public const int TitleMaxLength = 120;
public const int SubjectMaxLength = 40;
public const int MinGradeLevel = 1;
public const int MaxGradeLevel = 6;
public const int MaxItems = 60;
public const int AttemptsLimit = 20;
public const int MaxTimeLimitMinutes = 240;
public const int MaxTotalPoints = 100;
public const int GradeThresholdsMaxLength = 40;
public const int AiPromptMaxLength = 2000;
public const int AiProviderMaxLength = 100;
public string Title { get; set; } = string.Empty;
/// <summary>Text aus <see cref="LearningSubjects.All"/>.</summary>
public string Subject { get; set; } = LearningSubjects.Math;
public int GradeLevel { get; set; } = 1;
/// <summary>Das Kind, dem das Blatt zugewiesen ist (Rolle Kind; ohne Fremdschlüssel wie bei Aufgaben).</summary>
public Guid TargetUserId { get; set; }
/// <summary>Fälligkeitstag in der Familienzeitzone.</summary>
public DateOnly? DueDate { get; set; }
public LearningSetStatus Status { get; set; } = LearningSetStatus.Draft;
public LearningMode Mode { get; set; } = LearningMode.Practice;
/// <summary>Anzahl Versuche; 0 = unbegrenzt.</summary>
public int MaxAttempts { get; set; } = 1;
/// <summary>Zeitlimit je Versuch in Minuten; <c>null</c> = keins.</summary>
public int? TimeLimitMinutes { get; set; }
/// <summary>Musterlösung und Erklärung nach der Abgabe zeigen.</summary>
public bool ShowSolutionsAfterSubmit { get; set; }
/// <summary>Aufgabenreihenfolge je Versuch mischen (stabil je Versuch).</summary>
public bool ShuffleItems { get; set; }
/// <summary>Punkte, die das Blatt bei voller Punktzahl im Punktebuch ergibt; 0 = keine Punkte.</summary>
public int TotalPoints { get; set; }
/// <summary>Notenschlüssel für Prüfungen: Mindestprozent für die Noten 1 bis 5 (z. B. <c>92;81;67;50;30</c>); <c>null</c> = Standard.</summary>
public string? GradeThresholds { get; set; }
public LearningSource Source { get; set; } = LearningSource.Manual;
/// <summary>Nur bei <see cref="LearningSource.Ai"/> (Phase 10b): verwendeter Prompt, Anbieter und Modell.</summary>
public string? AiPrompt { get; set; }
public string? AiProvider { get; set; }
public string? AiModel { get; set; }
public DateTimeOffset? AssignedAt { get; set; }
/// <summary>Wird bei jeder Änderung an den Aufgaben erhöht und sperrt so gleichzeitige Bearbeitung (zusammen mit <see cref="RowVersion"/>).</summary>
public int ItemsRevision { get; set; }
public List<LearningItem> Items { get; set; } = [];
public List<LearningAttempt> Attempts { get; set; } = [];
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Guid? DeletedBy { get; set; }
public byte[] RowVersion { get; set; } = [];
}
/// <summary>Eine Aufgabe eines Blatts.</summary>
public sealed class LearningItem : AuditableEntity, ISoftDeletable, IHasRowVersion
{
public const int QuestionMaxLength = 1000;
public const int CorrectAnswerMaxLength = 200;
public const int HintMaxLength = 500;
public const int ExplanationMaxLength = 1000;
public const int OptionMaxLength = 100;
public const int MinOptions = 2;
public const int MaxOptions = 6;
public const int MinDifficulty = 1;
public const int MaxDifficulty = 3;
public const int MinPoints = 1;
public const int MaxPoints = 20;
public Guid SetId { get; set; }
public int SortOrder { get; set; }
public string Question { get; set; } = string.Empty;
public LearningAnswerType AnswerType { get; set; } = LearningAnswerType.Number;
/// <summary>
/// Musterlösung: Zahl (<see cref="LearningAnswerType.Number"/>), Text bzw. mehrere mit <c>|</c> getrennte Varianten
/// (<see cref="LearningAnswerType.Text"/>) oder der Index der richtigen Option (<see cref="LearningAnswerType.SingleChoice"/>).
/// </summary>
public string? CorrectAnswer { get; set; }
/// <summary>Absolute Toleranz für Zahlenantworten; <c>null</c> = exakt.</summary>
public decimal? NumberTolerance { get; set; }
/// <summary>Auswahloptionen (nur <see cref="LearningAnswerType.SingleChoice"/>).</summary>
public List<string> Options { get; set; } = [];
/// <summary>Hinweis für das Kind – nur im Übungsmodus sichtbar.</summary>
public string? Hint { get; set; }
/// <summary>Erklärung zur Musterlösung – nur nach der Abgabe und nur wenn „Lösungen zeigen“ aktiv ist.</summary>
public string? Explanation { get; set; }
/// <summary>1 = leicht, 2 = mittel, 3 = schwer.</summary>
public int Difficulty { get; set; } = 1;
public int Points { get; set; } = 1;
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Guid? DeletedBy { get; set; }
public byte[] RowVersion { get; set; } = [];
}
/// <summary>Ein Bearbeitungsversuch eines Kindes. Wiederholungen sind weitere Versuche (<see cref="Number"/> zählt hoch).</summary>
public sealed class LearningAttempt : AuditableEntity, IHasRowVersion
{
public const int NoteMaxLength = 500;
public const int ItemOrderMaxLength = 2400;
public Guid SetId { get; set; }
public Guid UserId { get; set; }
/// <summary>1, 2, 3 … je Blatt und Kind.</summary>
public int Number { get; set; }
public LearningAttemptStatus Status { get; set; } = LearningAttemptStatus.InProgress;
public DateTimeOffset StartedAt { get; set; }
/// <summary>Ablauf des Zeitlimits (vom Server beim Start festgelegt); <c>null</c> = kein Limit.</summary>
public DateTimeOffset? DeadlineAt { get; set; }
/// <summary>Letzte Speicherung einer Antwort. Das Ändern schützt per <see cref="RowVersion"/> vor Speichern während der Abgabe.</summary>
public DateTimeOffset LastActivityAt { get; set; }
public DateTimeOffset? SubmittedAt { get; set; }
/// <summary>Abgabe erfolgte wegen Zeitablauf, nicht durch das Kind.</summary>
public bool AutoSubmitted { get; set; }
/// <summary>Kommagetrennte Aufgaben-Ids in der Reihenfolge dieses Versuchs (stabil, ggf. gemischt).</summary>
public string ItemOrder { get; set; } = string.Empty;
public DateTimeOffset? ReviewedAt { get; set; }
public Guid? ReviewedBy { get; set; }
/// <summary>Erreichte Punkte, gültig ab <see cref="LearningAttemptStatus.Reviewed"/>.</summary>
public int? EarnedPoints { get; set; }
public int? TotalItemPoints { get; set; }
/// <summary>Schulnote 1–6, nur für Prüfungen und gültig ab <see cref="LearningAttemptStatus.Reviewed"/>.</summary>
public int? Grade { get; set; }
public string? ReviewNote { get; set; }
public List<LearningAnswer> Answers { get; set; } = [];
public byte[] RowVersion { get; set; } = [];
}
/// <summary>Antwort des Kindes auf eine Aufgabe und deren Bewertung.</summary>
public sealed class LearningAnswer : AuditableEntity
{
public const int ValueMaxLength = 500;
public const int ReasonMaxLength = 500;
public Guid AttemptId { get; set; }
public Guid ItemId { get; set; }
/// <summary>Antwort des Kindes (Zahl/Text als Eingabe, bei Auswahl der Index der Option); <c>null</c> = nicht beantwortet.</summary>
public string? Value { get; set; }
public DateTimeOffset? AnsweredAt { get; set; }
public LearningVerdict Verdict { get; set; } = LearningVerdict.Open;
public LearningGrader Grader { get; set; } = LearningGrader.Auto;
/// <summary>Erreichte Punkte dieser Aufgabe (0 bis Aufgabenpunkte).</summary>
public int AwardedPoints { get; set; }
public string? Reason { get; set; }
public DateTimeOffset? GradedAt { get; set; }
}
@@ -0,0 +1,224 @@
using System.Globalization;
using System.Text;
namespace FamilyPlanner.Domain.Learning;
/// <summary>Ergebnis der automatischen Bewertung einer Antwort.</summary>
public sealed record AutoGradeResult(LearningVerdict Verdict, int AwardedPoints, string? Reason = null);
/// <summary>
/// Liest Zahlen, wie Kinder sie tippen: Komma oder Punkt als Dezimalzeichen, Punkt/Komma/Leerzeichen als Tausendertrenner.
/// Läuft unabhängig von der Kultur des Geräts (immer <see cref="CultureInfo.InvariantCulture"/>), damit Api und WASM-Client gleich rechnen.
/// Brüche („1/2“) werden bewusst nicht erkannt.
/// </summary>
public static class LearningNumbers
{
private const int MaxLength = 28;
/// <summary>
/// Mögliche Werte der Eingabe. Meist genau einer; bei „1.234“ bzw. „1,234“ (genau drei Nachkommastellen, 1–3 Ziffern davor)
/// sind Dezimalzahl und Tausendertrennung möglich – dann kommen beide Werte zurück (Dezimalwert zuerst).
/// </summary>
public static IReadOnlyList<decimal> Parse(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return [];
}
var builder = new StringBuilder(text.Length);
foreach (var c in text.Trim())
{
switch (c)
{
case ' ' or ' ' or ' ' or ' ':
continue; // Tausender-Leerzeichen
case '−' or '–':
builder.Append('-');
break;
default:
builder.Append(c);
break;
}
}
var s = builder.ToString();
if (s.Length == 0 || s.Length > MaxLength)
{
return [];
}
var negative = false;
if (s[0] is '-' or '+')
{
negative = s[0] == '-';
s = s[1..];
}
if (s.Length == 0 || !s.All(c => char.IsAsciiDigit(c) || c is '.' or ',') || !s.Any(char.IsAsciiDigit))
{
return [];
}
var candidates = Candidates(s);
return candidates
.Select(value => negative ? -value : value)
.ToList();
}
/// <summary>Erster (bevorzugter) Wert oder <c>null</c>, wenn keine Zahl erkannt wurde.</summary>
public static decimal? ParseFirst(string? text) => Parse(text) is { Count: > 0 } list ? list[0] : null;
private static List<decimal> Candidates(string s)
{
var dots = s.Count(c => c == '.');
var commas = s.Count(c => c == ',');
if (dots == 0 && commas == 0)
{
return Single(TryDecimal(s));
}
if (dots > 0 && commas > 0)
{
// Das letzte Trennzeichen ist das Komma der Dezimalstelle, das andere trennt Tausender.
var last = s.LastIndexOfAny(['.', ',']);
var decimalSeparator = s[last];
var thousandsSeparator = decimalSeparator == '.' ? ',' : '.';
if (s.Count(c => c == decimalSeparator) != 1)
{
return [];
}
var integerPart = s[..last];
var fraction = s[(last + 1)..];
return IsThousandsGrouped(integerPart, thousandsSeparator)
? Single(TryDecimal(integerPart.Replace(thousandsSeparator.ToString(), string.Empty) + "." + fraction))
: [];
}
var separator = dots > 0 ? '.' : ',';
var count = Math.Max(dots, commas);
if (count > 1)
{
// Mehrfach vorkommend: nur als Tausendertrenner sinnvoll („1.234.567“).
return IsThousandsGrouped(s, separator)
? Single(TryDecimal(s.Replace(separator.ToString(), string.Empty)))
: [];
}
var index = s.IndexOf(separator);
var before = s[..index];
var after = s[(index + 1)..];
var asDecimal = TryDecimal((before.Length == 0 ? "0" : before) + "." + after);
var ambiguous = after.Length == 3 && before.Length is >= 1 and <= 3 && before[0] != '0' && after.All(char.IsAsciiDigit);
var result = Single(asDecimal);
if (ambiguous && TryDecimal(before + after) is { } grouped)
{
result.Add(grouped);
}
return result;
}
/// <summary>1–3 Ziffern, danach Gruppen aus genau drei Ziffern, getrennt durch <paramref name="separator"/>.</summary>
private static bool IsThousandsGrouped(string text, char separator)
{
var groups = text.Split(separator);
return groups.Length >= 2
&& groups[0].Length is >= 1 and <= 3
&& groups.All(g => g.All(char.IsAsciiDigit))
&& groups.Skip(1).All(g => g.Length == 3);
}
private static List<decimal> Single(decimal? value) => value is { } v ? [v] : [];
private static decimal? TryDecimal(string text) =>
decimal.TryParse(text, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var value) ? value : null;
}
/// <summary>Automatische Bewertung ohne KI (ADR-0021 Punkt 3): Zahlen und Auswahl endgültig, Freitext nur bei exakter Übereinstimmung.</summary>
public static class LearningGrading
{
/// <summary>Text vergleichbar machen: Unicode-Normalform, Leerraum zusammenfassen, Groß-/Kleinschreibung und Randzeichen ignorieren.</summary>
public static string NormalizeText(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return string.Empty;
}
var normalized = text.Normalize(NormalizationForm.FormC);
var builder = new StringBuilder(normalized.Length);
var pendingSpace = false;
foreach (var c in normalized)
{
if (char.IsWhiteSpace(c))
{
pendingSpace = builder.Length > 0;
continue;
}
if (pendingSpace)
{
builder.Append(' ');
pendingSpace = false;
}
builder.Append(c);
}
return builder.ToString().Trim().TrimEnd('.', '!', '?', ';').Trim().ToLowerInvariant();
}
/// <summary>Bewertet die Antwort einer Aufgabe. Eine leere Antwort ist immer falsch.</summary>
public static AutoGradeResult Grade(LearningItem item, string? answer)
{
if (string.IsNullOrWhiteSpace(answer))
{
return new AutoGradeResult(LearningVerdict.Wrong, 0, "Keine Antwort.");
}
return item.AnswerType switch
{
LearningAnswerType.Number => GradeNumber(item, answer),
LearningAnswerType.SingleChoice => GradeChoice(item, answer),
LearningAnswerType.Text => GradeText(item, answer),
_ => new AutoGradeResult(LearningVerdict.Open, 0), // Paper: Prüfung durch Eltern (Phase 10c)
};
}
private static AutoGradeResult GradeNumber(LearningItem item, string answer)
{
var given = LearningNumbers.Parse(answer);
if (given.Count == 0)
{
return new AutoGradeResult(LearningVerdict.Wrong, 0, "Keine Zahl erkannt.");
}
var expected = LearningNumbers.Parse(item.CorrectAnswer);
var tolerance = Math.Abs(item.NumberTolerance ?? 0m);
var correct = given.Any(g => expected.Any(e => Math.Abs(g - e) <= tolerance));
return correct ? Correct(item) : new AutoGradeResult(LearningVerdict.Wrong, 0);
}
private static AutoGradeResult GradeChoice(LearningItem item, string answer)
{
var correct = int.TryParse(answer.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var chosen)
&& int.TryParse(item.CorrectAnswer?.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var expected)
&& chosen == expected
&& chosen < item.Options.Count;
return correct ? Correct(item) : new AutoGradeResult(LearningVerdict.Wrong, 0);
}
private static AutoGradeResult GradeText(LearningItem item, string answer)
{
var given = NormalizeText(answer);
var accepted = (item.CorrectAnswer ?? string.Empty).Split('|').Select(NormalizeText).Where(a => a.Length > 0);
return accepted.Contains(given, StringComparer.Ordinal)
? Correct(item)
: new AutoGradeResult(LearningVerdict.Open, 0); // Abweichungen entscheiden die Eltern (Schreibweise, Synonyme)
}
private static AutoGradeResult Correct(LearningItem item) => new(LearningVerdict.Correct, item.Points);
}
@@ -0,0 +1,394 @@
using System.Security.Cryptography;
using FamilyPlanner.Domain.Authorization;
namespace FamilyPlanner.Domain.Learning;
/// <summary>Der handelnde Benutzer mit seinen Lern-Rechten (Rollenmatrix, ADR-0008); gleich gebildet in Api und Client.</summary>
public sealed record LearningActor(Guid UserId, bool CanManage, AccessLevel Solve)
{
public static LearningActor From(Guid userId, IEnumerable<string> roles, PermissionOptions? options = null)
{
var list = roles.ToList();
return new LearningActor(
userId,
PermissionMatrix.For(Permission.LearningManage, list, options) == AccessLevel.Full,
PermissionMatrix.For(Permission.LearningSolve, list, options));
}
}
/// <summary>Voraussetzung für einen neuen Versuch.</summary>
public enum AttemptStart
{
Ok = 0,
/// <summary>Blatt ist (noch) nicht zugewiesen.</summary>
NotAssigned = 1,
/// <summary>Es läuft bereits ein Versuch – dieser wird fortgesetzt.</summary>
Running = 2,
/// <summary>Alle erlaubten Versuche sind verbraucht.</summary>
NoAttemptsLeft = 3,
}
/// <summary>Was das Kind zu einer Aufgabe sehen darf – Grundlage aller Kinder-DTOs (nie Lösungen vorab).</summary>
public sealed record LearningVisibility(bool ShowHint, bool ShowVerdict, bool ShowSolution);
/// <summary>Fachliche Regeln der Lernaufgaben, rein und ohne Abhängigkeit von EF/ASP.NET (ADR-0021).</summary>
public static class LearningRules
{
/// <summary>Feste Toleranz für Netzverzögerungen: Speicherungen bis so lange nach dem Ablauf werden noch akzeptiert.</summary>
public static readonly TimeSpan DeadlineTolerance = TimeSpan.FromSeconds(10);
// ---------- Rechte ----------
/// <summary>Lösen darf nur die Zielperson, und nur zugewiesene Blätter (Entwürfe sind für Kinder unsichtbar).</summary>
public static bool CanSolve(LearningActor actor, Guid targetUserId, LearningSetStatus status) =>
actor.Solve != AccessLevel.None && targetUserId == actor.UserId && status != LearningSetStatus.Draft;
// ---------- Zeitlimit ----------
/// <summary>Ablaufzeitpunkt eines Versuchs oder <c>null</c> ohne Zeitlimit.</summary>
public static DateTimeOffset? DeadlineFor(DateTimeOffset startedAt, int? timeLimitMinutes) =>
timeLimitMinutes is > 0 ? startedAt.AddMinutes(timeLimitMinutes.Value) : null;
/// <summary>Antworten dürfen noch gespeichert werden (Frist plus feste Toleranz).</summary>
public static bool AcceptsSaves(DateTimeOffset? deadline, DateTimeOffset now) =>
deadline is null || now <= deadline.Value + DeadlineTolerance;
/// <summary>Die Frist samt Toleranz ist vorbei: der Versuch muss automatisch abgegeben werden.</summary>
public static bool MustAutoSubmit(DateTimeOffset? deadline, DateTimeOffset now) => !AcceptsSaves(deadline, now);
/// <summary>Zeitstempel der Abgabe: nie nach der Frist.</summary>
public static DateTimeOffset SubmittedAtFor(DateTimeOffset? deadline, DateTimeOffset now) =>
deadline is { } d && now > d ? d : now;
/// <summary>Verbleibende Zeit (nie negativ) oder <c>null</c> ohne Zeitlimit.</summary>
public static TimeSpan? Remaining(DateTimeOffset? deadline, DateTimeOffset now) =>
deadline is { } d ? (d - now < TimeSpan.Zero ? TimeSpan.Zero : d - now) : null;
// ---------- Versuche und Status ----------
/// <summary>Status des Blatts aus den Versuchen (ab Zuweisung). Entwürfe bleiben Entwürfe.</summary>
public static LearningSetStatus DeriveStatus(LearningSetStatus current, IReadOnlyCollection<LearningAttemptStatus> attempts)
{
if (current == LearningSetStatus.Draft)
{
return LearningSetStatus.Draft;
}
if (attempts.Contains(LearningAttemptStatus.InProgress))
{
return LearningSetStatus.InProgress;
}
if (attempts.Contains(LearningAttemptStatus.Submitted))
{
return LearningSetStatus.Submitted;
}
return attempts.Contains(LearningAttemptStatus.Reviewed) ? LearningSetStatus.Reviewed : LearningSetStatus.Assigned;
}
/// <summary>Darf ein neuer Versuch gestartet werden? <paramref name="maxAttempts"/> = 0 bedeutet unbegrenzt.</summary>
public static AttemptStart CanStartAttempt(LearningSetStatus status, int maxAttempts, IReadOnlyCollection<LearningAttemptStatus> attempts)
{
if (status == LearningSetStatus.Draft)
{
return AttemptStart.NotAssigned;
}
if (attempts.Contains(LearningAttemptStatus.InProgress))
{
return AttemptStart.Running;
}
return maxAttempts > 0 && attempts.Count >= maxAttempts ? AttemptStart.NoAttemptsLeft : AttemptStart.Ok;
}
public static int AttemptsLeft(int maxAttempts, int used) => maxAttempts <= 0 ? int.MaxValue : Math.Max(0, maxAttempts - used);
/// <summary>Aufgaben dürfen nur geändert werden, solange es noch keinen Versuch gibt (sonst ändern sich Bewertungen nachträglich).</summary>
public static bool CanEditItems(int attemptCount) => attemptCount == 0;
/// <summary>Zuweisen: mindestens eine Aufgabe, jede mit gültiger Musterlösung. Liefert eine Fehlermeldung oder <c>null</c>.</summary>
public static string? CheckAssignable(LearningSetStatus status, IReadOnlyCollection<LearningItem> items)
{
if (status != LearningSetStatus.Draft)
{
return "Das Blatt ist bereits zugewiesen.";
}
if (items.Count == 0)
{
return "Das Blatt braucht mindestens eine Aufgabe.";
}
var broken = items.FirstOrDefault(i => LearningItemRules.CheckSolution(i) is not null);
return broken is null ? null : $"Aufgabe {broken.SortOrder + 1}: {LearningItemRules.CheckSolution(broken)}";
}
// ---------- Sichtbarkeit ----------
/// <summary>
/// Übung: Hinweise und Sofort-Rückmeldung (nur richtig/falsch, nie die Lösung) während der Bearbeitung.
/// Prüfung: während der Bearbeitung nichts. Nach der Abgabe: Bewertung immer, Lösung nur wenn eingestellt, Hinweise nur in der Übung.
/// </summary>
public static LearningVisibility VisibilityFor(LearningMode mode, LearningAttemptStatus status, bool showSolutionsAfterSubmit)
{
var practice = mode == LearningMode.Practice;
return status == LearningAttemptStatus.InProgress
? new LearningVisibility(ShowHint: practice, ShowVerdict: practice, ShowSolution: false)
: new LearningVisibility(ShowHint: practice, ShowVerdict: true, ShowSolution: showSolutionsAfterSubmit);
}
// ---------- Reihenfolge ----------
/// <summary>
/// Aufgaben-Reihenfolge eines Versuchs. Gemischt wird deterministisch (SHA-256 aus Versuchs- und Aufgaben-Id), damit die
/// Reihenfolge je Versuch stabil ist, aber zwischen Versuchen wechselt. <c>string.GetHashCode</c> wäre je Prozess zufällig.
/// </summary>
public static List<Guid> OrderItems(IReadOnlyList<Guid> itemIdsInSortOrder, bool shuffle, Guid attemptId)
{
if (!shuffle)
{
return [.. itemIdsInSortOrder];
}
return itemIdsInSortOrder
.Select(id => (Id: id, Key: ShuffleKey(attemptId, id)))
.OrderBy(x => x.Key)
.ThenBy(x => x.Id)
.Select(x => x.Id)
.ToList();
}
private static ulong ShuffleKey(Guid attemptId, Guid itemId)
{
Span<byte> bytes = stackalloc byte[32];
attemptId.TryWriteBytes(bytes[..16]);
itemId.TryWriteBytes(bytes[16..]);
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(bytes, hash);
return BitConverter.ToUInt64(hash[..8]);
}
public static string SerializeOrder(IEnumerable<Guid> ids) => string.Join(',', ids);
public static List<Guid> ParseOrder(string? text) =>
string.IsNullOrWhiteSpace(text)
? []
: text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part => Guid.TryParse(part, out var id) ? id : Guid.Empty)
.Where(id => id != Guid.Empty)
.ToList();
// ---------- Bewertung durch Eltern ----------
/// <summary>
/// Prüft eine Elternbewertung und liefert die zu speichernden Punkte. Richtig = volle Punkte, falsch = 0, teilweise = 1 bis
/// Aufgabenpunkte − 1 (ohne Angabe die Hälfte, abgerundet, mindestens 1). „Offen“ ist als Ergebnis nicht erlaubt.
/// </summary>
public static (int Points, string? Error) NormalizeParentVerdict(LearningVerdict verdict, int? awardedPoints, int itemPoints)
{
switch (verdict)
{
case LearningVerdict.Correct:
return (itemPoints, null);
case LearningVerdict.Wrong:
return (0, null);
case LearningVerdict.Partial:
if (itemPoints < 2)
{
return (0, "Teilpunkte gibt es erst ab 2 Aufgabenpunkten.");
}
var points = awardedPoints ?? Math.Max(1, itemPoints / 2);
return points is >= 1 && points <= itemPoints - 1
? (points, null)
: (0, $"Teilpunkte müssen zwischen 1 und {itemPoints - 1} liegen.");
default:
return (0, "Bitte „richtig“, „teilweise“ oder „falsch“ wählen.");
}
}
// ---------- Punkte ----------
/// <summary>Erreichte Punkte (offene Antworten zählen 0), Gesamtpunkte und Anzahl offener Antworten.</summary>
public static LearningScore Score(IEnumerable<ScoredAnswer> answers)
{
int earned = 0, total = 0, open = 0, correct = 0, count = 0;
foreach (var answer in answers)
{
count++;
total += answer.ItemPoints;
switch (answer.Verdict)
{
case LearningVerdict.Open:
open++;
break;
case LearningVerdict.Correct:
correct++;
earned += answer.ItemPoints;
break;
default:
earned += Math.Clamp(answer.AwardedPoints, 0, answer.ItemPoints);
break;
}
}
return new LearningScore(earned, total, open, correct, count);
}
/// <summary>
/// Punkte, die das Blatt insgesamt im Punktebuch ergeben soll: die <b>beste</b> bestätigte Wertung (Anteil erreichter Punkte)
/// mal <paramref name="setTotalPoints"/>, kaufmännisch auf ganze Punkte gerundet (halbe aufwärts). Ohne bestätigten Versuch 0.
/// </summary>
public static int TargetCredit(int setTotalPoints, IEnumerable<(int Earned, int Total)> confirmedAttempts)
{
if (setTotalPoints <= 0)
{
return 0;
}
(int Earned, int Total)? best = null;
foreach (var attempt in confirmedAttempts.Where(a => a.Total > 0))
{
// Brüche ohne Gleitkomma vergleichen: a/b > c/d <=> a*d > c*b
if (best is null || (long)attempt.Earned * best.Value.Total > (long)best.Value.Earned * attempt.Total)
{
best = attempt;
}
}
return best is { } b ? (int)((2L * b.Earned * setTotalPoints + b.Total) / (2L * b.Total)) : 0;
}
/// <summary>Zu buchende Differenz zum bereits gebuchten Stand (negativ = Rückbuchung).</summary>
public static int CreditDelta(int target, int alreadyBooked) => target - alreadyBooked;
}
/// <summary>Eine bewertete Antwort für die Punkteberechnung.</summary>
public sealed record ScoredAnswer(int ItemPoints, LearningVerdict Verdict, int AwardedPoints);
public sealed record LearningScore(int Earned, int Total, int OpenCount, int CorrectCount, int AnswerCount)
{
public double Percent => Total <= 0 ? 0 : Earned * 100.0 / Total;
}
/// <summary>Notenschlüssel: Mindestprozent für die Noten 1 bis 5; darunter gibt es die 6. Standard: Schulnoten 92/81/67/50/30.</summary>
public static class LearningGrades
{
public static IReadOnlyList<int> DefaultThresholds { get; } = [92, 81, 67, 50, 30];
/// <summary>Liest <c>92;81;67;50;30</c>; leer = Standard. Fünf absteigende Prozentwerte zwischen 1 und 100.</summary>
public static bool TryParse(string? text, out IReadOnlyList<int> thresholds, out string? error)
{
thresholds = DefaultThresholds;
error = null;
if (string.IsNullOrWhiteSpace(text))
{
return true;
}
var parts = text.Split([';', ','], StringSplitOptions.TrimEntries);
var values = new List<int>();
foreach (var part in parts)
{
if (!int.TryParse(part, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var value))
{
error = "Der Notenschlüssel braucht fünf Prozentwerte, getrennt durch Semikolon (z. B. 92;81;67;50;30).";
return false;
}
values.Add(value);
}
if (values.Count != 5 || values.Any(v => v is < 1 or > 100))
{
error = "Der Notenschlüssel braucht fünf Prozentwerte zwischen 1 und 100.";
return false;
}
for (var i = 1; i < values.Count; i++)
{
if (values[i] >= values[i - 1])
{
error = "Die Prozentwerte des Notenschlüssels müssen von Note 1 bis 5 abnehmen.";
return false;
}
}
thresholds = values;
return true;
}
/// <summary>Normalisierte Schreibweise zum Speichern; <c>null</c> für den Standard.</summary>
public static string? Format(IReadOnlyList<int> thresholds) =>
thresholds.SequenceEqual(DefaultThresholds) ? null : string.Join(';', thresholds);
/// <summary>Note 1–6 aus erreichten und möglichen Punkten (exakter Ganzzahlvergleich, keine Rundungsfehler).</summary>
public static int GradeFor(int earned, int total, IReadOnlyList<int>? thresholds = null)
{
thresholds ??= DefaultThresholds;
if (total <= 0)
{
return 6;
}
for (var i = 0; i < thresholds.Count; i++)
{
if (earned * 100L >= (long)thresholds[i] * total)
{
return i + 1;
}
}
return 6;
}
public static string Label(int grade) => grade switch
{
1 => "sehr gut",
2 => "gut",
3 => "befriedigend",
4 => "ausreichend",
5 => "mangelhaft",
6 => "ungenügend",
_ => string.Empty,
};
}
/// <summary>Prüfung der Felder einer Aufgabe, die der Validator ohne Entität nicht kennt.</summary>
public static class LearningItemRules
{
/// <summary>Typ-spezifische Prüfung der Musterlösung. Liefert eine Fehlermeldung oder <c>null</c>.</summary>
public static string? CheckSolution(LearningItem item)
{
switch (item.AnswerType)
{
case LearningAnswerType.Number:
return LearningNumbers.Parse(item.CorrectAnswer).Count == 0 ? "Die Musterlösung muss eine Zahl sein." : null;
case LearningAnswerType.Text:
return string.IsNullOrWhiteSpace(item.CorrectAnswer) ? "Bitte eine Musterlösung angeben." : null;
case LearningAnswerType.SingleChoice:
if (item.Options.Count is < LearningItem.MinOptions or > LearningItem.MaxOptions)
{
return $"Eine Auswahlaufgabe braucht {LearningItem.MinOptions} bis {LearningItem.MaxOptions} Optionen.";
}
return int.TryParse(item.CorrectAnswer, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var index)
&& index < item.Options.Count
? null
: "Bitte die richtige Option wählen.";
default:
return "Papieraufgaben sind noch nicht verfügbar.";
}
}
/// <summary>Antworttypen, die in dieser Ausbaustufe angelegt werden dürfen (<see cref="LearningAnswerType.Paper"/> folgt in Phase 10c).</summary>
public static bool IsSupported(LearningAnswerType type) => type is LearningAnswerType.Number or LearningAnswerType.Text or LearningAnswerType.SingleChoice;
/// <summary>Vorschlag für die Punkte nach Schwierigkeit (leicht 1, mittel 2, schwer 3).</summary>
public static int DefaultPoints(int difficulty) => Math.Clamp(difficulty, LearningItem.MinDifficulty, LearningItem.MaxDifficulty);
}
@@ -228,5 +228,8 @@ public sealed class PointsLedgerEntry : AuditableEntity
public Guid? TaskId { get; set; }
/// <summary>Buchung zu einem Lernblatt (ADR-0021): Gutschrift, Verbesserung oder Rückbuchung.</summary>
public Guid? LearningSetId { get; set; }
public string Reason { get; set; } = string.Empty;
}
@@ -2,6 +2,7 @@ using FamilyPlanner.Domain.Ai;
using FamilyPlanner.Domain.Audit;
using FamilyPlanner.Domain.Calendar;
using FamilyPlanner.Domain.Chat;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Domain.Meals;
using FamilyPlanner.Domain.Push;
using FamilyPlanner.Domain.Settings;
@@ -70,6 +71,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<AiUsage> AiUsage => Set<AiUsage>();
public DbSet<LearningSet> LearningSets => Set<LearningSet>();
public DbSet<LearningItem> LearningItems => Set<LearningItem>();
public DbSet<LearningAttempt> LearningAttempts => Set<LearningAttempt>();
public DbSet<LearningAnswer> LearningAnswers => Set<LearningAnswer>();
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -0,0 +1,83 @@
using FamilyPlanner.Domain.Learning;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace FamilyPlanner.Infrastructure.Persistence.Configurations;
internal sealed class LearningSetConfiguration : IEntityTypeConfiguration<LearningSet>
{
public void Configure(EntityTypeBuilder<LearningSet> builder)
{
builder.ToTable("LearningSets");
builder.Property(s => s.Title).HasMaxLength(LearningSet.TitleMaxLength).IsRequired();
builder.Property(s => s.Subject).HasMaxLength(LearningSet.SubjectMaxLength).IsRequired();
builder.Property(s => s.Status).HasConversion<string>().HasMaxLength(20);
builder.Property(s => s.Mode).HasConversion<string>().HasMaxLength(20);
builder.Property(s => s.Source).HasConversion<string>().HasMaxLength(20);
builder.Property(s => s.GradeThresholds).HasMaxLength(LearningSet.GradeThresholdsMaxLength);
builder.Property(s => s.AiPrompt).HasMaxLength(LearningSet.AiPromptMaxLength);
builder.Property(s => s.AiProvider).HasMaxLength(LearningSet.AiProviderMaxLength);
builder.Property(s => s.AiModel).HasMaxLength(LearningSet.AiProviderMaxLength);
builder.Property(s => s.RowVersion).IsRowVersion();
builder.HasMany(s => s.Items).WithOne().HasForeignKey(i => i.SetId).OnDelete(DeleteBehavior.Cascade);
builder.HasMany(s => s.Attempts).WithOne().HasForeignKey(a => a.SetId).OnDelete(DeleteBehavior.Cascade);
// Kind-Ansicht „meine Blätter“ und Eltern-Übersicht.
builder.HasIndex(s => new { s.TargetUserId, s.Status });
builder.HasIndex(s => s.Status);
}
}
internal sealed class LearningItemConfiguration : IEntityTypeConfiguration<LearningItem>
{
public void Configure(EntityTypeBuilder<LearningItem> builder)
{
builder.ToTable("LearningItems");
builder.Property(i => i.Question).HasMaxLength(LearningItem.QuestionMaxLength).IsRequired();
builder.Property(i => i.AnswerType).HasConversion<string>().HasMaxLength(20);
builder.Property(i => i.CorrectAnswer).HasMaxLength(LearningItem.CorrectAnswerMaxLength);
builder.Property(i => i.NumberTolerance).HasPrecision(18, 6);
builder.PrimitiveCollection(i => i.Options).IsRequired();
builder.Property(i => i.Hint).HasMaxLength(LearningItem.HintMaxLength);
builder.Property(i => i.Explanation).HasMaxLength(LearningItem.ExplanationMaxLength);
builder.Property(i => i.RowVersion).IsRowVersion();
builder.HasIndex(i => new { i.SetId, i.SortOrder });
}
}
internal sealed class LearningAttemptConfiguration : IEntityTypeConfiguration<LearningAttempt>
{
public void Configure(EntityTypeBuilder<LearningAttempt> builder)
{
builder.ToTable("LearningAttempts");
builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(20);
builder.Property(a => a.ItemOrder).HasMaxLength(LearningAttempt.ItemOrderMaxLength).IsRequired();
builder.Property(a => a.ReviewNote).HasMaxLength(LearningAttempt.NoteMaxLength);
builder.Property(a => a.RowVersion).IsRowVersion();
// Der Benutzer ist eine bloße Id (kein Fremdschlüssel, wie bei Aufgaben) – das vermeidet mehrfache Kaskadenpfade.
builder.HasMany(a => a.Answers).WithOne().HasForeignKey(x => x.AttemptId).OnDelete(DeleteBehavior.Cascade);
// Versuchsnummern sind je Blatt und Kind eindeutig; höchstens ein laufender Versuch – schützt vor Doppelklick/zwei Tabs
// (umgeht weder MaxAttempts noch setzt es die Prüfungsuhr zurück).
builder.HasIndex(a => new { a.SetId, a.UserId, a.Number }).IsUnique();
builder.HasIndex(a => new { a.SetId, a.UserId }).IsUnique().HasFilter("[Status] = 'InProgress'");
builder.HasIndex(a => new { a.Status, a.DeadlineAt });
}
}
internal sealed class LearningAnswerConfiguration : IEntityTypeConfiguration<LearningAnswer>
{
public void Configure(EntityTypeBuilder<LearningAnswer> builder)
{
builder.ToTable("LearningAnswers");
builder.Property(a => a.Value).HasMaxLength(LearningAnswer.ValueMaxLength);
builder.Property(a => a.Verdict).HasConversion<string>().HasMaxLength(20);
builder.Property(a => a.Grader).HasConversion<string>().HasMaxLength(20);
builder.Property(a => a.Reason).HasMaxLength(LearningAnswer.ReasonMaxLength);
// ItemId ist bewusst keine Beziehung (Aufgaben werden nach dem ersten Versuch nicht mehr verändert; kein zweiter Kaskadenpfad).
builder.HasIndex(a => new { a.AttemptId, a.ItemId }).IsUnique();
}
}
@@ -72,6 +72,7 @@ internal sealed class PointsLedgerEntryConfiguration : IEntityTypeConfiguration<
builder.Property(p => p.Reason).HasMaxLength(PointsLedgerEntry.ReasonMaxLength).IsRequired();
builder.HasIndex(p => p.UserId);
builder.HasIndex(p => p.TaskId);
builder.HasIndex(p => p.LearningSetId);
}
}
@@ -0,0 +1,241 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase10aLearning : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "LearningSetId",
table: "PointsLedger",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateTable(
name: "LearningSets",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Title = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: false),
Subject = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
GradeLevel = table.Column<int>(type: "int", nullable: false),
TargetUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
DueDate = table.Column<DateOnly>(type: "date", nullable: true),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
Mode = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
MaxAttempts = table.Column<int>(type: "int", nullable: false),
TimeLimitMinutes = table.Column<int>(type: "int", nullable: true),
ShowSolutionsAfterSubmit = table.Column<bool>(type: "bit", nullable: false),
ShuffleItems = table.Column<bool>(type: "bit", nullable: false),
TotalPoints = table.Column<int>(type: "int", nullable: false),
GradeThresholds = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: true),
Source = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
AiPrompt = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
AiProvider = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
AiModel = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
AssignedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
ItemsRevision = table.Column<int>(type: "int", 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),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false),
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_LearningSets", x => x.Id);
});
migrationBuilder.CreateTable(
name: "LearningAttempts",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SetId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Number = table.Column<int>(type: "int", nullable: false),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
StartedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
DeadlineAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
LastActivityAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
SubmittedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
AutoSubmitted = table.Column<bool>(type: "bit", nullable: false),
ItemOrder = table.Column<string>(type: "nvarchar(2400)", maxLength: 2400, nullable: false),
ReviewedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
ReviewedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
EarnedPoints = table.Column<int>(type: "int", nullable: true),
TotalItemPoints = table.Column<int>(type: "int", nullable: true),
Grade = table.Column<int>(type: "int", nullable: true),
ReviewNote = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false),
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_LearningAttempts", x => x.Id);
table.ForeignKey(
name: "FK_LearningAttempts_LearningSets_SetId",
column: x => x.SetId,
principalTable: "LearningSets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "LearningItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SetId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
Question = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
AnswerType = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
CorrectAnswer = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
NumberTolerance = table.Column<decimal>(type: "decimal(18,6)", precision: 18, scale: 6, nullable: true),
Options = table.Column<string>(type: "nvarchar(max)", nullable: false),
Hint = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
Explanation = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
Difficulty = table.Column<int>(type: "int", nullable: false),
Points = table.Column<int>(type: "int", 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),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false),
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_LearningItems", x => x.Id);
table.ForeignKey(
name: "FK_LearningItems_LearningSets_SetId",
column: x => x.SetId,
principalTable: "LearningSets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "LearningAnswers",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
AttemptId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ItemId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Value = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
AnsweredAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
Verdict = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
Grader = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
AwardedPoints = table.Column<int>(type: "int", nullable: false),
Reason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
GradedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", 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_LearningAnswers", x => x.Id);
table.ForeignKey(
name: "FK_LearningAnswers_LearningAttempts_AttemptId",
column: x => x.AttemptId,
principalTable: "LearningAttempts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PointsLedger_LearningSetId",
table: "PointsLedger",
column: "LearningSetId");
migrationBuilder.CreateIndex(
name: "IX_LearningAnswers_AttemptId_ItemId",
table: "LearningAnswers",
columns: new[] { "AttemptId", "ItemId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_LearningAttempts_SetId_UserId",
table: "LearningAttempts",
columns: new[] { "SetId", "UserId" },
unique: true,
filter: "[Status] = 'InProgress'");
migrationBuilder.CreateIndex(
name: "IX_LearningAttempts_SetId_UserId_Number",
table: "LearningAttempts",
columns: new[] { "SetId", "UserId", "Number" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_LearningAttempts_Status_DeadlineAt",
table: "LearningAttempts",
columns: new[] { "Status", "DeadlineAt" });
migrationBuilder.CreateIndex(
name: "IX_LearningItems_IsDeleted",
table: "LearningItems",
column: "IsDeleted");
migrationBuilder.CreateIndex(
name: "IX_LearningItems_SetId_SortOrder",
table: "LearningItems",
columns: new[] { "SetId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_LearningSets_IsDeleted",
table: "LearningSets",
column: "IsDeleted");
migrationBuilder.CreateIndex(
name: "IX_LearningSets_Status",
table: "LearningSets",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_LearningSets_TargetUserId_Status",
table: "LearningSets",
columns: new[] { "TargetUserId", "Status" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "LearningAnswers");
migrationBuilder.DropTable(
name: "LearningItems");
migrationBuilder.DropTable(
name: "LearningAttempts");
migrationBuilder.DropTable(
name: "LearningSets");
migrationBuilder.DropIndex(
name: "IX_PointsLedger_LearningSetId",
table: "PointsLedger");
migrationBuilder.DropColumn(
name: "LearningSetId",
table: "PointsLedger");
}
}
}
@@ -682,6 +682,355 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.ToTable("ChatReactions", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningAnswer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("AnsweredAt")
.HasColumnType("datetimeoffset");
b.Property<Guid>("AttemptId")
.HasColumnType("uniqueidentifier");
b.Property<int>("AwardedPoints")
.HasColumnType("int");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("GradedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Grader")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<Guid>("ItemId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Reason")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Value")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Verdict")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.HasKey("Id");
b.HasIndex("AttemptId", "ItemId")
.IsUnique();
b.ToTable("LearningAnswers", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningAttempt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<bool>("AutoSubmitted")
.HasColumnType("bit");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("DeadlineAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("EarnedPoints")
.HasColumnType("int");
b.Property<int?>("Grade")
.HasColumnType("int");
b.Property<string>("ItemOrder")
.IsRequired()
.HasMaxLength(2400)
.HasColumnType("nvarchar(2400)");
b.Property<DateTimeOffset>("LastActivityAt")
.HasColumnType("datetimeoffset");
b.Property<int>("Number")
.HasColumnType("int");
b.Property<string>("ReviewNote")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTimeOffset?>("ReviewedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("ReviewedBy")
.HasColumnType("uniqueidentifier");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<Guid>("SetId")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("StartedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("TotalItemPoints")
.HasColumnType("int");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("SetId", "UserId")
.IsUnique()
.HasFilter("[Status] = 'InProgress'");
b.HasIndex("Status", "DeadlineAt");
b.HasIndex("SetId", "UserId", "Number")
.IsUnique();
b.ToTable("LearningAttempts", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AnswerType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("CorrectAnswer")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
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<int>("Difficulty")
.HasColumnType("int");
b.Property<string>("Explanation")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Hint")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<decimal?>("NumberTolerance")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.PrimitiveCollection<string>("Options")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Points")
.HasColumnType("int");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<Guid>("SetId")
.HasColumnType("uniqueidentifier");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("IsDeleted");
b.HasIndex("SetId", "SortOrder");
b.ToTable("LearningItems", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningSet", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AiModel")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("AiPrompt")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("AiProvider")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset?>("AssignedAt")
.HasColumnType("datetimeoffset");
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<DateOnly?>("DueDate")
.HasColumnType("date");
b.Property<int>("GradeLevel")
.HasColumnType("int");
b.Property<string>("GradeThresholds")
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("ItemsRevision")
.HasColumnType("int");
b.Property<int>("MaxAttempts")
.HasColumnType("int");
b.Property<string>("Mode")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<bool>("ShowSolutionsAfterSubmit")
.HasColumnType("bit");
b.Property<bool>("ShuffleItems")
.HasColumnType("bit");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Subject")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<Guid>("TargetUserId")
.HasColumnType("uniqueidentifier");
b.Property<int?>("TimeLimitMinutes")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<int>("TotalPoints")
.HasColumnType("int");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("IsDeleted");
b.HasIndex("Status");
b.HasIndex("TargetUserId", "Status");
b.ToTable("LearningSets", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Meals.Dish", b =>
{
b.Property<Guid>("Id")
@@ -1166,6 +1515,9 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("LearningSetId")
.HasColumnType("uniqueidentifier");
b.Property<int>("Points")
.HasColumnType("int");
@@ -1188,6 +1540,8 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.HasIndex("LearningSetId");
b.HasIndex("TaskId");
b.HasIndex("UserId");
@@ -1791,6 +2145,33 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningAnswer", b =>
{
b.HasOne("FamilyPlanner.Domain.Learning.LearningAttempt", null)
.WithMany("Answers")
.HasForeignKey("AttemptId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningAttempt", b =>
{
b.HasOne("FamilyPlanner.Domain.Learning.LearningSet", null)
.WithMany("Attempts")
.HasForeignKey("SetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningItem", b =>
{
b.HasOne("FamilyPlanner.Domain.Learning.LearningSet", null)
.WithMany("Items")
.HasForeignKey("SetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Shopping.ShoppingItem", b =>
{
b.HasOne("FamilyPlanner.Domain.Shopping.ShoppingList", null)
@@ -1883,6 +2264,18 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.Navigation("Permissions");
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningAttempt", b =>
{
b.Navigation("Answers");
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningSet", b =>
{
b.Navigation("Attempts");
b.Navigation("Items");
});
modelBuilder.Entity("FamilyPlanner.Domain.Tasks.TodoTask", b =>
{
b.Navigation("Assignments");
@@ -0,0 +1,498 @@
using FamilyPlanner.Domain.Learning;
using FluentValidation;
namespace FamilyPlanner.Shared.Learning;
// =====================================================================================================================
// Eltern (Permission LearningManage): Blätter, Aufgaben mit Lösungen, Prüfung. Diese DTOs gehen NIE an Kinder.
// =====================================================================================================================
/// <summary>Aufgabe mit Musterlösung, Hinweis und Erklärung – nur für Eltern.</summary>
public sealed record LearningItemDto(
Guid Id,
int SortOrder,
string Question,
LearningAnswerType AnswerType,
string? CorrectAnswer,
decimal? NumberTolerance,
IReadOnlyList<string> Options,
string? Hint,
string? Explanation,
int Difficulty,
int Points,
byte[] Version);
/// <summary>Blatt ohne Aufgaben (Listen, Übersicht) inkl. Kennzahlen; Prompt/Anbieter/Modell nur für Eltern.</summary>
public sealed record LearningSetDto(
Guid Id,
string Title,
string Subject,
int GradeLevel,
Guid TargetUserId,
DateOnly? DueDate,
LearningSetStatus Status,
LearningMode Mode,
int MaxAttempts,
int? TimeLimitMinutes,
bool ShowSolutionsAfterSubmit,
bool ShuffleItems,
int TotalPoints,
string? GradeThresholds,
LearningSource Source,
string? AiPrompt,
string? AiProvider,
string? AiModel,
DateTimeOffset? AssignedAt,
int ItemCount,
int AttemptCount,
int PendingReviewCount,
int PointsCredited,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
byte[] Version);
public sealed record LearningAttemptSummaryDto(
Guid Id,
int Number,
LearningAttemptStatus Status,
DateTimeOffset StartedAt,
DateTimeOffset? SubmittedAt,
bool AutoSubmitted,
int? EarnedPoints,
int? TotalItemPoints,
int? Grade);
public sealed record LearningSetDetailDto(
LearningSetDto Set,
IReadOnlyList<LearningItemDto> Items,
IReadOnlyList<LearningAttemptSummaryDto> Attempts);
/// <summary>Gemeinsame, bearbeitbare Felder einer Aufgabe.</summary>
public interface ILearningItemFields
{
string Question { get; }
LearningAnswerType AnswerType { get; }
string? CorrectAnswer { get; }
decimal? NumberTolerance { get; }
IReadOnlyList<string>? Options { get; }
string? Hint { get; }
string? Explanation { get; }
int Difficulty { get; }
int Points { get; }
}
/// <summary>Neue Aufgabe (auch als Teil von <see cref="CreateLearningSetRequest.Items"/>).</summary>
public sealed record LearningItemInput(
string Question,
LearningAnswerType AnswerType,
string? CorrectAnswer,
decimal? NumberTolerance = null,
IReadOnlyList<string>? Options = null,
string? Hint = null,
string? Explanation = null,
int Difficulty = 1,
int Points = 1) : ILearningItemFields;
/// <summary>Aufgabe ändern. Passt <see cref="Version"/> nicht, antwortet die Api mit 409 und dem aktuellen Stand.</summary>
public sealed record UpdateLearningItemRequest(
string Question,
LearningAnswerType AnswerType,
string? CorrectAnswer,
decimal? NumberTolerance = null,
IReadOnlyList<string>? Options = null,
string? Hint = null,
string? Explanation = null,
int Difficulty = 1,
int Points = 1,
byte[]? Version = null) : ILearningItemFields
{
public static UpdateLearningItemRequest From(LearningItemDto item) => new(
item.Question, item.AnswerType, item.CorrectAnswer, item.NumberTolerance, item.Options, item.Hint, item.Explanation,
item.Difficulty, item.Points, item.Version);
}
public sealed record ReorderLearningItemsRequest(IReadOnlyList<Guid> ItemIds);
/// <summary>Gemeinsame Einstellungen eines Blatts.</summary>
public interface ILearningSetFields
{
string Title { get; }
string Subject { get; }
int GradeLevel { get; }
Guid TargetUserId { get; }
DateOnly? DueDate { get; }
LearningMode Mode { get; }
int MaxAttempts { get; }
int? TimeLimitMinutes { get; }
bool ShowSolutionsAfterSubmit { get; }
bool ShuffleItems { get; }
int TotalPoints { get; }
string? GradeThresholds { get; }
}
/// <summary>Neues Blatt (als Entwurf), optional gleich mit Aufgaben. Herkunft „KI“ setzt nur der Server (Phase 10b).</summary>
public sealed record CreateLearningSetRequest(
string Title,
string Subject,
int GradeLevel,
Guid TargetUserId,
DateOnly? DueDate = null,
LearningMode Mode = LearningMode.Practice,
int MaxAttempts = 0,
int? TimeLimitMinutes = null,
bool ShowSolutionsAfterSubmit = true,
bool ShuffleItems = false,
int TotalPoints = 0,
string? GradeThresholds = null,
IReadOnlyList<LearningItemInput>? Items = null) : ILearningSetFields;
/// <summary>Blatt ändern (alle Einstellungen). Passt <see cref="Version"/> nicht, antwortet die Api mit 409 und dem aktuellen Stand.</summary>
public sealed record UpdateLearningSetRequest(
string Title,
string Subject,
int GradeLevel,
Guid TargetUserId,
DateOnly? DueDate = null,
LearningMode Mode = LearningMode.Practice,
int MaxAttempts = 0,
int? TimeLimitMinutes = null,
bool ShowSolutionsAfterSubmit = true,
bool ShuffleItems = false,
int TotalPoints = 0,
string? GradeThresholds = null,
byte[]? Version = null) : ILearningSetFields
{
public static UpdateLearningSetRequest From(LearningSetDto set) => new(
set.Title, set.Subject, set.GradeLevel, set.TargetUserId, set.DueDate, set.Mode, set.MaxAttempts, set.TimeLimitMinutes,
set.ShowSolutionsAfterSubmit, set.ShuffleItems, set.TotalPoints, set.GradeThresholds, set.Version);
}
/// <summary>Eintrag der Prüf-Warteschlange: abgegebener, noch nicht bestätigter Versuch.</summary>
public sealed record LearningReviewQueueItemDto(
Guid AttemptId,
Guid SetId,
string SetTitle,
string Subject,
LearningMode Mode,
Guid ChildId,
string ChildName,
int Number,
DateTimeOffset SubmittedAt,
bool AutoSubmitted,
int OpenCount,
int EarnedPoints,
int TotalItemPoints);
/// <summary>Aufgabe eines Versuchs aus Elternsicht: Frage, Musterlösung, Kinderantwort und Bewertung.</summary>
public sealed record LearningReviewAnswerDto(
Guid ItemId,
int Position,
string Question,
LearningAnswerType AnswerType,
IReadOnlyList<string> Options,
string? CorrectAnswer,
string? Explanation,
int ItemPoints,
string? Answer,
LearningVerdict Verdict,
LearningGrader Grader,
int AwardedPoints,
string? Reason);
public sealed record LearningAttemptReviewDto(
Guid AttemptId,
Guid SetId,
string SetTitle,
string Subject,
LearningMode Mode,
int SetTotalPoints,
Guid ChildId,
string ChildName,
int Number,
LearningAttemptStatus Status,
DateTimeOffset StartedAt,
DateTimeOffset? SubmittedAt,
bool AutoSubmitted,
IReadOnlyList<LearningReviewAnswerDto> Answers,
int EarnedPoints,
int TotalItemPoints,
int OpenCount,
int? Grade,
int PointsCredited,
string? ReviewNote,
byte[] Version);
/// <summary>Elternbewertung einer Antwort: richtig, teilweise (mit Punkten) oder falsch; optional mit Begründung.</summary>
public sealed record ReviewAnswerRequest(LearningVerdict Verdict, int? AwardedPoints = null, string? Reason = null);
public sealed record ConfirmAttemptRequest(string? Note = null);
/// <summary>Ergebnis von Bestätigen/Erneut öffnen: der Versuch und die gebuchte Punktedifferenz (negativ = Rückbuchung).</summary>
public sealed record LearningReviewResultDto(LearningAttemptReviewDto Attempt, int PointsDelta, int PointsCredited);
// =====================================================================================================================
// Kind (Permission LearningSolve): eigene Blätter lösen. Eigene Typen OHNE Musterlösung/Prompt/Anbieter – Lösungen und
// Hinweise stehen nur in <see cref="LearningAttemptItemDto"/>, wenn die Regeln es erlauben (LearningRules.VisibilityFor).
// =====================================================================================================================
/// <summary>Ergebnis eines Versuchs aus Kindersicht (nach der Abgabe). <see cref="IsFinal"/>: von Eltern bestätigt.</summary>
public sealed record MyLearningResultDto(
Guid AttemptId,
int Number,
LearningAttemptStatus Status,
DateTimeOffset? SubmittedAt,
bool AutoSubmitted,
int EarnedPoints,
int TotalItemPoints,
int OpenCount,
int? Grade,
bool IsFinal);
/// <summary>Blatt in „Meine Blätter“ – ohne Aufgaben.</summary>
public sealed record MyLearningSetDto(
Guid Id,
string Title,
string Subject,
int GradeLevel,
DateOnly? DueDate,
LearningSetStatus Status,
LearningMode Mode,
int? TimeLimitMinutes,
int ItemCount,
int TotalPoints,
int MaxAttempts,
int AttemptsUsed,
bool CanStart,
Guid? RunningAttemptId,
int PointsCredited,
IReadOnlyList<MyLearningResultDto> Results);
/// <summary>Rückmeldung zu einer Antwort bzw. nach der Abgabe; Felder nur, wenn <c>LearningRules.VisibilityFor</c> sie erlaubt.</summary>
public sealed record LearningItemFeedbackDto(
LearningVerdict Verdict,
int AwardedPoints,
string? Reason,
string? CorrectAnswer,
string? Explanation);
/// <summary>Aufgabe in der Kinderansicht. <see cref="Hint"/> nur im Übungsmodus, <see cref="Feedback"/> nur wenn erlaubt.</summary>
public sealed record LearningAttemptItemDto(
Guid ItemId,
int Position,
string Question,
LearningAnswerType AnswerType,
IReadOnlyList<string> Options,
int Points,
int Difficulty,
string? Answer,
string? Hint,
LearningItemFeedbackDto? Feedback);
/// <summary>Ein Versuch aus Kindersicht. <see cref="ServerNow"/> dient dem Client zum Abgleich der Uhr (Countdown zur <see cref="DeadlineAt"/>).</summary>
public sealed record LearningAttemptDto(
Guid Id,
Guid SetId,
string SetTitle,
string Subject,
LearningMode Mode,
int Number,
LearningAttemptStatus Status,
DateTimeOffset StartedAt,
DateTimeOffset? DeadlineAt,
DateTimeOffset ServerNow,
bool AutoSubmitted,
IReadOnlyList<LearningAttemptItemDto> Items,
MyLearningResultDto? Result,
byte[] Version);
/// <summary>Antwort speichern (leer = löschen). Idempotent und zustandsbasiert: es zählt der zuletzt gespeicherte Wert.</summary>
public sealed record SaveAnswerRequest(string? Value);
/// <summary>Bestätigung einer gespeicherten Antwort; <see cref="Verdict"/> nur im Übungsmodus (Sofort-Rückmeldung, nie die Lösung).</summary>
public sealed record SaveAnswerResultDto(
Guid ItemId,
bool Answered,
LearningVerdict? Verdict,
DateTimeOffset? DeadlineAt,
DateTimeOffset ServerNow);
// =====================================================================================================================
// Live und Routen
// =====================================================================================================================
/// <summary>Namen der SignalR-Ereignisse: ohne Inhalte, nur an Zielperson und Eltern; der Client lädt über die geschützten Endpunkte nach.</summary>
public static class LearningEvents
{
public const string Changed = "LearningChanged";
}
public enum LearningChangeKind
{
SetChanged = 0,
Assigned = 1,
Submitted = 2,
Reviewed = 3,
Deleted = 4,
}
public sealed record LearningChangedDto(Guid SetId, LearningChangeKind Kind);
// =====================================================================================================================
// Validierung (Api verbindlich, Client für sofortiges Feedback)
// =====================================================================================================================
public static class LearningValidation
{
internal static void SetFields<T>(AbstractValidator<T> validator)
where T : ILearningSetFields
{
validator.RuleFor(r => r.Title)
.Must(t => !string.IsNullOrWhiteSpace(t)).WithMessage("Bitte einen Titel eingeben.")
.Must(t => t is null || t.Trim().Length <= LearningSet.TitleMaxLength)
.WithMessage($"Der Titel darf höchstens {LearningSet.TitleMaxLength} Zeichen haben.");
validator.RuleFor(r => r.Subject).Must(LearningSubjects.IsValid).WithMessage("Bitte ein Fach wählen (Mathe oder Deutsch).");
validator.RuleFor(r => r.GradeLevel).InclusiveBetween(LearningSet.MinGradeLevel, LearningSet.MaxGradeLevel)
.WithMessage("Die Klassenstufe muss zwischen 1 und 6 liegen.");
validator.RuleFor(r => r.TargetUserId).NotEmpty().WithMessage("Bitte ein Kind auswählen.");
validator.RuleFor(r => r.Mode).IsInEnum().WithMessage("Unbekannter Modus.");
validator.RuleFor(r => r.MaxAttempts).InclusiveBetween(0, LearningSet.AttemptsLimit)
.WithMessage($"Die Zahl der Versuche muss zwischen 0 (unbegrenzt) und {LearningSet.AttemptsLimit} liegen.");
validator.RuleFor(r => r.TimeLimitMinutes).InclusiveBetween(1, LearningSet.MaxTimeLimitMinutes)
.When(r => r.TimeLimitMinutes is not null)
.WithMessage($"Das Zeitlimit muss zwischen 1 und {LearningSet.MaxTimeLimitMinutes} Minuten liegen.");
validator.RuleFor(r => r.TotalPoints).InclusiveBetween(0, LearningSet.MaxTotalPoints)
.WithMessage($"Die Gesamtpunkte müssen zwischen 0 und {LearningSet.MaxTotalPoints} liegen.");
validator.RuleFor(r => r.GradeThresholds)
.Must(text => LearningGrades.TryParse(text, out _, out _))
.WithMessage((request, text) => LearningGrades.TryParse(text, out _, out var error) ? string.Empty : error ?? "Ungültiger Notenschlüssel.");
}
internal static void ItemFields<T>(AbstractValidator<T> validator)
where T : ILearningItemFields
{
validator.RuleFor(r => r.Question)
.Must(q => !string.IsNullOrWhiteSpace(q)).WithMessage("Bitte eine Frage eingeben.")
.Must(q => q is null || q.Trim().Length <= LearningItem.QuestionMaxLength)
.WithMessage($"Die Frage darf höchstens {LearningItem.QuestionMaxLength} Zeichen haben.");
validator.RuleFor(r => r.AnswerType).Must(LearningItemRules.IsSupported)
.WithMessage("Dieser Antworttyp ist noch nicht verfügbar.");
validator.RuleFor(r => r.CorrectAnswer).MaximumLength(LearningItem.CorrectAnswerMaxLength)
.WithMessage($"Die Musterlösung darf höchstens {LearningItem.CorrectAnswerMaxLength} Zeichen haben.");
validator.RuleFor(r => r.Hint).MaximumLength(LearningItem.HintMaxLength)
.WithMessage($"Der Hinweis darf höchstens {LearningItem.HintMaxLength} Zeichen haben.");
validator.RuleFor(r => r.Explanation).MaximumLength(LearningItem.ExplanationMaxLength)
.WithMessage($"Die Erklärung darf höchstens {LearningItem.ExplanationMaxLength} Zeichen haben.");
validator.RuleFor(r => r.Difficulty).InclusiveBetween(LearningItem.MinDifficulty, LearningItem.MaxDifficulty)
.WithMessage("Die Schwierigkeit muss leicht, mittel oder schwer sein.");
validator.RuleFor(r => r.Points).InclusiveBetween(LearningItem.MinPoints, LearningItem.MaxPoints)
.WithMessage($"Die Punkte müssen zwischen {LearningItem.MinPoints} und {LearningItem.MaxPoints} liegen.");
validator.RuleFor(r => r.NumberTolerance).InclusiveBetween(0m, 1_000_000m).When(r => r.NumberTolerance is not null)
.WithMessage("Die Toleranz muss zwischen 0 und 1.000.000 liegen.");
validator.RuleFor(r => r.Options)
.Must(options => options is null || options.All(o => !string.IsNullOrWhiteSpace(o) && o.Trim().Length <= LearningItem.OptionMaxLength))
.WithMessage($"Optionen brauchen einen Text mit höchstens {LearningItem.OptionMaxLength} Zeichen.");
validator.RuleFor(r => r)
.Must(r => LearningItemRules.CheckSolution(ToItem(r)) is null)
.When(r => LearningItemRules.IsSupported(r.AnswerType))
.WithName(nameof(ILearningItemFields.CorrectAnswer))
.WithMessage(r => LearningItemRules.CheckSolution(ToItem(r)) ?? string.Empty);
}
/// <summary>Entität aus den Eingabefeldern (bereinigt) – Grundlage der Prüfung und des Speicherns.</summary>
public static LearningItem ToItem(ILearningItemFields fields) => new()
{
Question = fields.Question?.Trim() ?? string.Empty,
AnswerType = fields.AnswerType,
CorrectAnswer = string.IsNullOrWhiteSpace(fields.CorrectAnswer) ? null : fields.CorrectAnswer.Trim(),
NumberTolerance = fields.AnswerType == LearningAnswerType.Number ? fields.NumberTolerance : null,
Options = fields.AnswerType == LearningAnswerType.SingleChoice
? (fields.Options ?? []).Select(o => o?.Trim() ?? string.Empty).ToList()
: [],
Hint = string.IsNullOrWhiteSpace(fields.Hint) ? null : fields.Hint.Trim(),
Explanation = string.IsNullOrWhiteSpace(fields.Explanation) ? null : fields.Explanation.Trim(),
Difficulty = fields.Difficulty,
Points = fields.Points,
};
}
public sealed class CreateLearningSetRequestValidator : AbstractValidator<CreateLearningSetRequest>
{
public CreateLearningSetRequestValidator()
{
LearningValidation.SetFields(this);
RuleFor(r => r.Items)
.Must(items => items is null || items.Count <= LearningSet.MaxItems)
.WithMessage($"Ein Blatt kann höchstens {LearningSet.MaxItems} Aufgaben haben.");
RuleForEach(r => r.Items).SetValidator(new LearningItemInputValidator());
}
}
public sealed class UpdateLearningSetRequestValidator : AbstractValidator<UpdateLearningSetRequest>
{
public UpdateLearningSetRequestValidator() => LearningValidation.SetFields(this);
}
public sealed class LearningItemInputValidator : AbstractValidator<LearningItemInput>
{
public LearningItemInputValidator() => LearningValidation.ItemFields(this);
}
public sealed class UpdateLearningItemRequestValidator : AbstractValidator<UpdateLearningItemRequest>
{
public UpdateLearningItemRequestValidator() => LearningValidation.ItemFields(this);
}
public sealed class ReorderLearningItemsRequestValidator : AbstractValidator<ReorderLearningItemsRequest>
{
public ReorderLearningItemsRequestValidator() =>
RuleFor(r => r.ItemIds)
.NotNull().WithMessage("Die Reihenfolge fehlt.")
.Must(ids => ids is null || (ids.Count <= LearningSet.MaxItems && ids.Distinct().Count() == ids.Count && ids.All(id => id != Guid.Empty)))
.WithMessage("Ungültige Reihenfolge.");
}
public sealed class SaveAnswerRequestValidator : AbstractValidator<SaveAnswerRequest>
{
public SaveAnswerRequestValidator() =>
RuleFor(r => r.Value).MaximumLength(LearningAnswer.ValueMaxLength)
.WithMessage($"Die Antwort darf höchstens {LearningAnswer.ValueMaxLength} Zeichen haben.");
}
public sealed class ReviewAnswerRequestValidator : AbstractValidator<ReviewAnswerRequest>
{
public ReviewAnswerRequestValidator()
{
RuleFor(r => r.Verdict).Must(v => v is LearningVerdict.Correct or LearningVerdict.Partial or LearningVerdict.Wrong)
.WithMessage("Bitte „richtig“, „teilweise“ oder „falsch“ wählen.");
RuleFor(r => r.AwardedPoints).InclusiveBetween(1, LearningItem.MaxPoints).When(r => r.AwardedPoints is not null)
.WithMessage("Ungültige Teilpunkte.");
RuleFor(r => r.Reason).MaximumLength(LearningAnswer.ReasonMaxLength)
.WithMessage($"Die Begründung darf höchstens {LearningAnswer.ReasonMaxLength} Zeichen haben.");
}
}
public sealed class ConfirmAttemptRequestValidator : AbstractValidator<ConfirmAttemptRequest>
{
public ConfirmAttemptRequestValidator() =>
RuleFor(r => r.Note).MaximumLength(LearningAttempt.NoteMaxLength)
.WithMessage($"Der Kommentar darf höchstens {LearningAttempt.NoteMaxLength} Zeichen haben.");
}
@@ -29,6 +29,8 @@ public sealed class PermissionMatrixTests
{ Permission.FamilyChat, F, F, F, N }, // Gast: nur wenn freigegeben
{ Permission.DirectMessages, F, F, R, N }, // Kind: nur an Familienmitglieder
{ Permission.AiUse, F, F, N, N }, // KI nur für Eltern (ADR-0020)
{ Permission.LearningManage, F, F, N, N }, // Übungsblätter verwalten und prüfen (ADR-0021)
{ Permission.LearningSolve, F, F, R, N }, // Kind: nur eigene Blätter lösen (ADR-0021)
{ Permission.UserManagement, F, N, N, N },
{ Permission.CalendarManagement, F, N, N, N },
{ Permission.SettingsManagement, F, N, N, N },