Round 13: PWA Offline Mode, Rate Limiting, Behavior Analytics, Interventions, Audit Logging

This commit is contained in:
Mustafa Cetin committed 2026-07-29 10:23:50 +00:00
1 parent ceea8fb591
commit ff4dd670d4
33 files changed
+6327 -509

No files matched your search

+279 -2
View File
@@ -10,11 +10,27 @@ async function apiRequest<T>(url: string, options?: RequestInit): Promise<T> {
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
// Handle 429 Too Many Requests (rate limiting)
if (res.status === 429) {
const retryAfter = body.retryAfter || parseInt(res.headers.get('Retry-After') || '60', 10);
const rateLimitError = new Error(body.error || 'Too many requests');
(rateLimitError as RateLimitError).isRateLimit = true;
(rateLimitError as RateLimitError).retryAfter = retryAfter;
(rateLimitError as RateLimitError).limit = body.limit || 0;
throw rateLimitError;
}
throw new Error(body.error || `API error ${res.status}`);
}
return res.json();
}
// Rate limit error type
export interface RateLimitError extends Error {
isRateLimit: boolean;
retryAfter: number;
limit: number;
}
export function apiGet<T>(url: string): Promise<T> {
return apiRequest<T>(url);
}
@@ -703,23 +719,143 @@ export interface AuditLogEntry {
action: string;
entityType: string;
entityId: string | null;
changes: string | null;
ipAddress: string | null;
userAgent: string | null;
timestamp: string;
metadata: string | null;
user: { id: string; firstName: string; lastName: string } | null;
}
export interface AuditLogPaginatedResponse {
entries: AuditLogEntry[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
export function fetchAuditLog(params?: {
schoolId?: string;
action?: string;
entityType?: string;
userId?: string;
startDate?: string;
endDate?: string;
}): Promise<AuditLogEntry[]> {
page?: number;
pageSize?: number;
}): Promise<AuditLogPaginatedResponse> {
const sp = new URLSearchParams();
if (params?.schoolId) sp.set('schoolId', params.schoolId);
if (params?.action) sp.set('action', params.action);
if (params?.entityType) sp.set('entityType', params.entityType);
if (params?.userId) sp.set('userId', params.userId);
if (params?.startDate) sp.set('startDate', params.startDate);
if (params?.endDate) sp.set('endDate', params.endDate);
return apiGet<AuditLogEntry[]>(`/api/audit-log?${sp.toString()}`);
if (params?.page) sp.set('page', String(params.page));
if (params?.pageSize) sp.set('pageSize', String(params.pageSize));
return apiGet<AuditLogPaginatedResponse>(`/api/audit-log?${sp.toString()}`);
}
export function exportAuditLogCsv(params?: {
schoolId?: string;
action?: string;
entityType?: string;
userId?: string;
startDate?: string;
endDate?: string;
}): void {
const sp = new URLSearchParams();
sp.set('export', 'csv');
if (params?.schoolId) sp.set('schoolId', params.schoolId);
if (params?.action) sp.set('action', params.action);
if (params?.entityType) sp.set('entityType', params.entityType);
if (params?.userId) sp.set('userId', params.userId);
if (params?.startDate) sp.set('startDate', params.startDate);
if (params?.endDate) sp.set('endDate', params.endDate);
const url = `/api/audit-log?${sp.toString()}`;
const a = document.createElement('a');
a.href = url;
a.download = 'audit-log.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
/* ── Behavior Interventions ─────────────────────────────────────────── */
export interface BehaviorInterventionType {
id: string;
schoolId: string;
studentId: string;
incidentId: string | null;
type: string;
description: string;
status: string;
assignedTo: string | null;
startDate: string | null;
endDate: string | null;
outcome: string | null;
isDemo: boolean;
createdAt: string;
updatedAt: string;
student: { id: string; firstName: string; lastName: string };
incident: { id: string; description: string; date: string; severity: string } | null;
assignedUser: { id: string; firstName: string; lastName: string } | null;
}
export function fetchBehaviorInterventions(params: {
schoolId: string;
studentId?: string;
incidentId?: string;
type?: string;
status?: string;
}): Promise<BehaviorInterventionType[]> {
const sp = new URLSearchParams();
sp.set('schoolId', params.schoolId);
if (params.studentId) sp.set('studentId', params.studentId);
if (params.incidentId) sp.set('incidentId', params.incidentId);
if (params.type) sp.set('type', params.type);
if (params.status) sp.set('status', params.status);
return apiGet<BehaviorInterventionType[]>(`/api/behavior-interventions?${sp.toString()}`);
}
export function createBehaviorIntervention(data: {
schoolId: string;
studentId: string;
incidentId?: string;
type: string;
description: string;
status?: string;
assignedTo?: string;
startDate?: string;
endDate?: string;
outcome?: string;
isDemo?: boolean;
}): Promise<BehaviorInterventionType> {
return apiPost<BehaviorInterventionType>('/api/behavior-interventions', data);
}
export function updateBehaviorIntervention(
id: string,
data: {
type?: string;
description?: string;
status?: string;
assignedTo?: string;
startDate?: string;
endDate?: string;
outcome?: string;
incidentId?: string;
}
): Promise<BehaviorInterventionType> {
return apiPut<BehaviorInterventionType>(`/api/behavior-interventions/${id}`, data);
}
export function deleteBehaviorIntervention(id: string): Promise<{ success: boolean }> {
return apiDelete<{ success: boolean }>(`/api/behavior-interventions/${id}`);
}
/* ── Data Export (CSV) ────────────────────────────────────────────── */
@@ -2447,3 +2583,144 @@ export function bulkCreateStudentAccounts(data: {
}): Promise<{ created: Array<Record<string, unknown>>; count: number }> {
return apiPost<{ created: Array<Record<string, unknown>>; count: number }>('/api/users', { ...data, action: 'bulkCreateStudents' });
}
/* ── Report Schedules ──────────────────────────────────────────────── */
export interface ReportScheduleData {
id: string;
schoolId: string;
classGroupId: string | null;
template: string;
frequency: string;
dayOfWeek: number | null;
dayOfMonth: number | null;
monthOfYear: number | null;
recipients: string | null;
includeStudents: boolean;
includeGrades: boolean;
includeAttendance: boolean;
includeBehavior: boolean;
includeCompetencies: boolean;
isActive: boolean;
lastRunAt: string | null;
nextRunAt: string | null;
isDemo: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
classGroup?: { id: string; name: string; gradeLevel: number } | null;
}
export function fetchReportSchedules(schoolId: string): Promise<ReportScheduleData[]> {
return apiGet<ReportScheduleData[]>(`/api/report-schedules?schoolId=${schoolId}`);
}
export function createReportSchedule(data: {
schoolId: string;
classGroupId?: string;
template: string;
frequency: string;
dayOfWeek?: number | null;
dayOfMonth?: number | null;
monthOfYear?: number | null;
recipients?: string[];
includeStudents?: boolean;
includeGrades?: boolean;
includeAttendance?: boolean;
includeBehavior?: boolean;
includeCompetencies?: boolean;
}): Promise<ReportScheduleData> {
return apiPost<ReportScheduleData>('/api/report-schedules', data);
}
export function updateReportSchedule(id: string, data: {
classGroupId?: string;
template?: string;
frequency?: string;
dayOfWeek?: number | null;
dayOfMonth?: number | null;
monthOfYear?: number | null;
recipients?: string[];
includeStudents?: boolean;
includeGrades?: boolean;
includeAttendance?: boolean;
includeBehavior?: boolean;
includeCompetencies?: boolean;
isActive?: boolean;
}): Promise<ReportScheduleData> {
return apiPut<ReportScheduleData>(`/api/report-schedules/${id}`, data);
}
export function deleteReportSchedule(id: string): Promise<{ success: boolean }> {
return apiDelete<{ success: boolean }>(`/api/report-schedules/${id}`);
}
export function runReportSchedule(id: string): Promise<{
success: boolean;
lastRunAt: string;
nextRunAt: string;
recipientsNotified: number;
}> {
return apiPost(`/api/report-schedules/${id}/run`, {});
}
/* ── School Districts ──────────────────────────────────────────────── */
export interface SchoolDistrictData {
id: string;
name: string;
code: string | null;
region: string | null;
country: string;
adminEmail: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
schools: Array<{ id: string; name: string; schoolType: string; country: string }>;
schoolCount?: number;
totalStudents?: number;
}
export function fetchDistricts(): Promise<SchoolDistrictData[]> {
return apiGet<SchoolDistrictData[]>('/api/districts');
}
export function createDistrict(data: {
name: string;
code?: string;
region?: string;
country?: string;
adminEmail?: string;
}): Promise<SchoolDistrictData> {
return apiPost<SchoolDistrictData>('/api/districts', data);
}
export function updateDistrict(id: string, data: {
name?: string;
code?: string;
region?: string;
country?: string;
adminEmail?: string;
isActive?: boolean;
}): Promise<SchoolDistrictData> {
return apiPut<SchoolDistrictData>(`/api/districts/${id}`, data);
}
export function deleteDistrict(id: string): Promise<{ success: boolean }> {
return apiDelete<{ success: boolean }>(`/api/districts/${id}`);
}
export function fetchDistrictSchools(districtId: string): Promise<Array<{
id: string;
name: string;
schoolType: string;
country: string;
_count: { students: number; classGroups: number; users: number };
}>> {
return apiGet(`/api/districts/${districtId}/schools`);
}
export function assignSchoolToDistrict(districtId: string, schoolId: string): Promise<{ success: boolean }> {
return apiPost<{ success: boolean }>(`/api/districts/${districtId}/schools`, { schoolId });
}
+140
View File
@@ -0,0 +1,140 @@
// CompetenceTrack — Audit Logging Middleware
// Provides logAudit() and withAuditLog() helpers for recording all data modifications
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export interface AuditLogParams {
userId?: string;
schoolId?: string;
action: string; // CREATE, UPDATE, DELETE
entityType: string; // e.g., 'BehaviorIncident', 'Student', 'User'
entityId?: string;
changes?: {
before?: Record<string, unknown>;
after?: Record<string, unknown>;
};
ipAddress?: string;
userAgent?: string;
metadata?: Record<string, unknown>;
}
/**
* Log an audit entry to the database.
* Automatically resolves userId from session if not provided.
*/
export async function logAudit(params: AuditLogParams): Promise<void> {
try {
let userId = params.userId;
if (!userId) {
const session = await getSession();
userId = session?.user?.id ?? undefined;
}
await db.auditLog.create({
data: {
userId: userId ?? null,
schoolId: params.schoolId ?? null,
action: params.action,
entityType: params.entityType,
entityId: params.entityId ?? null,
changes: params.changes ? JSON.stringify(params.changes) : null,
ipAddress: params.ipAddress ?? null,
userAgent: params.userAgent ?? null,
metadata: params.metadata ? JSON.stringify(params.metadata) : null,
},
});
} catch (error) {
// Audit logging should never block the main operation
console.error('Audit log error:', error);
}
}
/**
* Extract client info from a Next.js Request object.
*/
export function extractClientInfo(request: Request): {
ipAddress?: string;
userAgent?: string;
} {
const forwarded = request.headers.get('x-forwarded-for');
const ipAddress = forwarded?.split(',')[0]?.trim() ?? request.headers.get('x-real-ip') ?? undefined;
const userAgent = request.headers.get('user-agent') ?? undefined;
return { ipAddress, userAgent };
}
/**
* Wrapper that adds audit logging to API route handlers.
* Automatically logs CREATE, UPDATE, DELETE actions with before/after data.
*
* Usage:
* ```ts
* export const POST = withAuditLog(async (request) => {
* // ... handler logic ...
* return NextResponse.json(result);
* }, 'CREATE', 'BehaviorIncident');
* ```
*/
export function withAuditLog(
handler: (request: Request, context?: { params?: Record<string, string | string[]> }) => Promise<Response>,
action: string,
entityType: string,
) {
return async (request: Request, context?: { params?: Record<string, string | string[]> }) => {
const clientInfo = extractClientInfo(request);
let session: { user?: { id?: string; schoolId?: string } } | null = null;
try {
session = await getSession();
} catch {
// Session may not be available for some endpoints
}
// Execute the handler
const response = await handler(request, context);
// If the handler was successful (2xx), log the audit entry
if (response.status >= 200 && response.status < 300) {
try {
// Try to extract entity ID from response body
let entityId: string | undefined;
let changes: AuditLogParams['changes'];
// Clone the response so we can read the body without consuming it
const clonedResponse = response.clone();
try {
const body = await clonedResponse.json();
entityId = body?.id ?? body?.data?.id ?? undefined;
if (action === 'UPDATE' && body?.data) {
changes = { after: body.data };
} else if (action === 'CREATE' && body?.data) {
changes = { after: body.data };
}
} catch {
// Response body may not be JSON
}
// Try to extract entity ID from URL params
if (!entityId && context?.params) {
const params = context.params;
entityId = params?.id as string | undefined;
}
await logAudit({
userId: session?.user?.id,
schoolId: session?.user?.schoolId,
action,
entityType,
entityId,
changes,
...clientInfo,
});
} catch (auditError) {
// Audit logging should never block the response
console.error('withAuditLog error:', auditError);
}
}
return response;
};
}
+396
View File
@@ -1388,6 +1388,93 @@ const de: TranslationDict = {
'behavior.duplicate_category': 'Eine Kategorie mit diesem Namen existiert bereits',
'behavior.confirm_close': 'Möchten Sie den Dialog wirklich schließen? Nicht gespeicherte Änderungen gehen verloren.',
// Behavior Analytics
'behavior.tab_analytics': 'Analyse',
'behavior.tab_interventions': 'Massnahmen',
'behavior.analytics.trend': 'Verlauf',
'behavior.analytics.trend_desc': 'Positive vs. negative Vorfälle im Zeitverlauf',
'behavior.analytics.category_dist': 'Kategorienverteilung',
'behavior.analytics.category_dist_desc': 'Aufschlüsselung nach Verhaltenskategorie',
'behavior.analytics.risk_profile': 'Risikoprofil',
'behavior.analytics.risk_profile_desc': 'Schüler:innen mit häufigen negativen Vorfällen',
'behavior.analytics.class_comparison': 'Klassenvergleich',
'behavior.analytics.class_comparison_desc': 'Verhaltensmuster im Klassenvergleich',
'behavior.analytics.time_of_day': 'Tageszeit-Analyse',
'behavior.analytics.time_of_day_desc': 'Wann Vorfälle am häufigsten auftreten',
'behavior.analytics.positive_trend': 'Positive Entwicklung',
'behavior.analytics.positive_trend_desc': 'Schüler:innen mit positiver Verhaltensentwicklung',
'behavior.analytics.positive_change': 'Positive Veränderung',
'behavior.analytics.negative_change': 'Negative Veränderung',
'behavior.analytics.period': 'Zeitraum',
'behavior.analytics.last_30': 'Letzte 30 Tage',
'behavior.analytics.last_90': 'Letzte 90 Tage',
'behavior.analytics.this_year': 'Dieses Schuljahr',
'behavior.analytics.risk_high': 'Hohes Risiko',
'behavior.analytics.risk_medium': 'Mittleres Risiko',
'behavior.analytics.risk_low': 'Niedriges Risiko',
'behavior.analytics.incidents_count_short': 'Vorfälle',
// Behavior Interventions
'behavior.intervention.title': 'Verhaltensmassnahmen',
'behavior.intervention.subtitle': 'Massnahmen erfassen und deren Wirksamkeit verfolgen',
'behavior.intervention.add': 'Massnahme hinzufügen',
'behavior.intervention.edit': 'Massnahme bearbeiten',
'behavior.intervention.type': 'Typ',
'behavior.intervention.type.warning': 'Verwarnung',
'behavior.intervention.type.meeting': 'Gespräch',
'behavior.intervention.type.action_plan': 'Aktionsplan',
'behavior.intervention.type.support': 'Unterstützung',
'behavior.intervention.type.parent_contact': 'Elternkontakt',
'behavior.intervention.description': 'Beschreibung',
'behavior.intervention.description_placeholder': 'Beschreibung der Massnahme…',
'behavior.intervention.status': 'Status',
'behavior.intervention.status.planned': 'Geplant',
'behavior.intervention.status.in_progress': 'In Bearbeitung',
'behavior.intervention.status.completed': 'Abgeschlossen',
'behavior.intervention.status.cancelled': 'Abgebrochen',
'behavior.intervention.assigned_to': 'Zugewiesen an',
'behavior.intervention.assigned_to_placeholder': 'Lehrkraft auswählen',
'behavior.intervention.start_date': 'Startdatum',
'behavior.intervention.end_date': 'Enddatum',
'behavior.intervention.outcome': 'Ergebnis',
'behavior.intervention.outcome_placeholder': 'Ergebnis der Massnahme…',
'behavior.intervention.linked_incident': 'Verknüpfter Vorfall',
'behavior.intervention.no_incident': 'Kein Vorfall verknüpft',
'behavior.intervention.effectiveness': 'Wirksamkeit',
'behavior.intervention.progress': 'Fortschritt',
'behavior.intervention.no_interventions': 'Keine Massnahmen vorhanden',
'behavior.intervention.no_interventions_desc': 'Erstellen Sie eine Massnahme, um das Verhalten zu unterstützen.',
'behavior.intervention.saved': 'Massnahme gespeichert',
'behavior.intervention.deleted': 'Massnahme gelöscht',
'behavior.intervention.error_save': 'Fehler beim Speichern der Massnahme',
'behavior.intervention.error_delete': 'Fehler beim Löschen der Massnahme',
'behavior.intervention.required_type': 'Bitte wählen Sie einen Typ aus',
'behavior.intervention.required_description': 'Bitte geben Sie eine Beschreibung ein',
'behavior.intervention.filter_status': 'Status filtern',
'behavior.intervention.all_statuses': 'Alle Status',
'behavior.intervention.filter_type': 'Typ filtern',
'behavior.intervention.all_types': 'Alle Typen',
// Audit Log Enhancement
'audit.entity_type': 'Entitätstyp',
'audit.entity_type_filter': 'Nach Typ filtern',
'audit.all_entity_types': 'Alle Typen',
'audit.user_filter': 'Benutzer filtern',
'audit.all_users': 'Alle Benutzer',
'audit.changes': 'Änderungen',
'audit.before': 'Vorher',
'audit.after': 'Nachher',
'audit.detail': 'Detailansicht',
'audit.export_csv': 'Als CSV exportieren',
'audit.page': 'Seite',
'audit.of': 'von',
'audit.entries': 'Einträge',
'audit.previous': 'Zurück',
'audit.next': 'Weiter',
'audit.no_changes': 'Keine Änderungsdetails verfügbar',
'audit.ip_address': 'IP-Adresse',
'audit.user_agent': 'Benutzer-Agent',
// Curriculum Coverage — Kompetenzabdeckung
'nav.coverage': 'Abdeckung',
'coverage.title': 'Kompetenzabdeckung',
@@ -2327,6 +2414,117 @@ const de: TranslationDict = {
'performance.title': 'Leistung',
'performance.fps': 'Bilder pro Sekunde',
'mobile.optimized': 'Für mobile Geräte optimiert',
// ── Report Scheduling (Task 13) ──
'schedules.title': 'Berichtsplanung',
'schedules.frequency': 'Häufigkeit',
'schedules.weekly': 'Wöchentlich',
'schedules.monthly': 'Monatlich',
'schedules.quarterly': 'Vierteljährlich',
'schedules.end_of_term': 'Ende des Halbjahres',
'schedules.next_run': 'Nächste Ausführung',
'schedules.last_run': 'Letzte Ausführung',
'schedules.manual_run': 'Manuell ausführen',
'schedules.recipients': 'Empfänger',
'schedules.include_students': 'Schüler einbeziehen',
'schedules.include_grades': 'Noten einbeziehen',
'schedules.include_attendance': 'Anwesenheit einbeziehen',
'schedules.include_behavior': 'Verhalten einbeziehen',
'schedules.include_competencies': 'Kompetenzen einbeziehen',
'schedules.create_schedule': 'Planung erstellen',
'schedules.edit_schedule': 'Planung bearbeiten',
'schedules.delete_schedule': 'Planung löschen',
'schedules.active': 'Aktiv',
'schedules.inactive': 'Inaktiv',
'schedules.no_schedules': 'Keine Berichtsplanungen vorhanden',
'schedules.no_schedules_desc': 'Erstellen Sie Ihre erste Berichtsplanung.',
'schedules.running': 'Wird ausgeführt...',
'schedules.run_success': 'Bericht erfolgreich generiert',
'schedules.run_error': 'Fehler bei der Berichtsgenerierung',
'schedules.day_of_week': 'Wochentag',
'schedules.day_of_month': 'Tag des Monats',
'schedules.month_of_year': 'Monat',
'schedules.template': 'Vorlage',
'schedules.all_classes': 'Alle Klassen',
'schedules.timeline': 'Zeitplan',
'schedules.confirm_delete': 'Planung löschen?',
'schedules.confirm_delete_desc': 'Diese Berichtsplanung wird unwiderruflich gelöscht.',
'schedules.confirm_run': 'Manuell ausführen?',
'schedules.confirm_run_desc': 'Der Bericht wird sofort generiert und an die Empfänger gesendet.',
'schedules.toggle_active': 'Aktivieren/Deaktivieren',
// ── School District (Task 13) ──
'district.title': 'Schulbezirk',
'district.schools': 'Schulen',
'district.comparison': 'Schulvergleich',
'district.analytics': 'Bezirksanalyse',
'district.create': 'Bezirk erstellen',
'district.edit': 'Bezirk bearbeiten',
'district.delete': 'Bezirk löschen',
'district.assign_school': 'Schule zuordnen',
'district.unassign_school': 'Schule entfernen',
'district.name': 'Name',
'district.code': 'Bezirk-Code',
'district.region': 'Region',
'district.country': 'Land',
'district.admin_email': 'Administrator-E-Mail',
'district.no_districts': 'Keine Schulbezirke vorhanden',
'district.no_districts_desc': 'Erstellen Sie Ihren ersten Schulbezirk.',
'district.school_count': 'Anzahl Schulen',
'district.student_count': 'Anzahl Schüler',
'district.confirm_delete': 'Bezirk löschen?',
'district.confirm_delete_desc': 'Dieser Schulbezirk wird gelöscht. Die zugeordneten Schulen bleiben erhalten.',
'district.top_performing': 'Beste Schule',
'district.needs_attention': 'Schulung empfohlen',
'district.avg_attendance': 'Durchschnittliche Anwesenheit',
'district.competency_coverage': 'Kompetenzabdeckung',
'district.no_schools_assigned': 'Keine Schulen zugeordnet',
'district.select_school': 'Schule auswählen',
'district.cross_school_comparison': 'Schulvergleich',
'district.total_students': 'Gesamtschüler',
'district.total_classes': 'Gesamtklassen',
'district.total_teachers': 'Gesamtlehrer',
// PWA / Offline
'pwa.offline': 'Sie sind offline',
'pwa.offline_desc': 'Ihre Daten werden aus dem Cache geladen.',
'pwa.back_online': 'Wieder online',
'pwa.back_online_desc': 'Ihre Verbindung wurde wiederhergestellt.',
'pwa.cached_data': 'Cache-Daten',
'pwa.install_app': 'App installieren',
'pwa.install_app_desc': 'CompetenceTrack als App auf Ihrem Gerät installieren.',
'pwa.install': 'Installieren',
'pwa.installed': 'App installiert',
'pwa.installed_desc': 'CompetenceTrack wurde erfolgreich als App installiert.',
'pwa.install_accepted': 'Installation gestartet',
'pwa.update_available': 'Update verfügbar',
'pwa.update_available_desc': 'Eine neue Version ist verfügbar. Laden Sie die Seite neu.',
'pwa.reload': 'Neu laden',
'pwa.offline_mode': 'Offline-Modus',
'pwa.pending_sync': '{count} ausstehende Synchronisationen',
'pwa.sync_complete': 'Synchronisation abgeschlossen',
'pwa.sync_complete_desc': '{count} Änderungen wurden erfolgreich synchronisiert.',
'pwa.notebook_cached': 'Offline verfügbar',
'pwa.notebook_cached_desc': 'Dieses Notizbuch ist für die Offline-Nutzung gespeichert.',
'pwa.cache_notebook': 'Offline speichern',
'pwa.cache_notebook_desc': 'Notizbuch für Offline-Zugriff speichern.',
'pwa.cached_at': 'Gespeichert am',
'pwa.queued_edits': 'Ausstehende Änderungen',
'pwa.queued_edits_desc': '{count} Änderungen werden synchronisiert, wenn Sie wieder online sind.',
// Rate Limiting
'rate_limit.title': 'Zu viele Anfragen',
'rate_limit.description': 'Bitte warten Sie {retryAfter} bevor Sie es erneut versuchen.',
'rate_limit.status_title': 'Rate-Limit-Status',
'rate_limit.active_entries': 'Aktive Einträge',
'rate_limit.top_consumers': 'Top-Verbraucher',
'rate_limit.requests': 'Anfragen',
'rate_limit.no_active': 'Keine aktiven Rate-Limits',
'rate_limit.unavailable': 'Rate-Limit-Status nicht verfügbar',
'rate_limit.auth_limit': 'Auth-Limit: 5/Min',
'rate_limit.data_limit': 'Daten-Limit: 60/Min',
'rate_limit.write_limit': 'Schreib-Limit: 30/Min',
'rate_limit.heavy_limit': 'Schwer-Limit: 10/5Min',
};
const en: TranslationDict = {
@@ -3714,6 +3912,93 @@ const en: TranslationDict = {
'behavior.duplicate_category': 'A category with this name already exists',
'behavior.confirm_close': 'Are you sure you want to close? Unsaved changes will be lost.',
// Behavior Analytics
'behavior.tab_analytics': 'Analytics',
'behavior.tab_interventions': 'Interventions',
'behavior.analytics.trend': 'Trend',
'behavior.analytics.trend_desc': 'Positive vs. negative incidents over time',
'behavior.analytics.category_dist': 'Category Distribution',
'behavior.analytics.category_dist_desc': 'Breakdown by behavior category',
'behavior.analytics.risk_profile': 'Risk Profile',
'behavior.analytics.risk_profile_desc': 'Students with frequent negative incidents',
'behavior.analytics.class_comparison': 'Class Comparison',
'behavior.analytics.class_comparison_desc': 'Compare behavior patterns across classes',
'behavior.analytics.time_of_day': 'Time-of-Day Analysis',
'behavior.analytics.time_of_day_desc': 'When incidents most frequently occur',
'behavior.analytics.positive_trend': 'Positive Reinforcement',
'behavior.analytics.positive_trend_desc': 'Students with positive behavior trends',
'behavior.analytics.positive_change': 'Positive change',
'behavior.analytics.negative_change': 'Negative change',
'behavior.analytics.period': 'Period',
'behavior.analytics.last_30': 'Last 30 days',
'behavior.analytics.last_90': 'Last 90 days',
'behavior.analytics.this_year': 'This school year',
'behavior.analytics.risk_high': 'High risk',
'behavior.analytics.risk_medium': 'Medium risk',
'behavior.analytics.risk_low': 'Low risk',
'behavior.analytics.incidents_count_short': 'Incidents',
// Behavior Interventions
'behavior.intervention.title': 'Behavior Interventions',
'behavior.intervention.subtitle': 'Track interventions and their effectiveness',
'behavior.intervention.add': 'Add Intervention',
'behavior.intervention.edit': 'Edit Intervention',
'behavior.intervention.type': 'Type',
'behavior.intervention.type.warning': 'Warning',
'behavior.intervention.type.meeting': 'Meeting',
'behavior.intervention.type.action_plan': 'Action Plan',
'behavior.intervention.type.support': 'Support',
'behavior.intervention.type.parent_contact': 'Parent Contact',
'behavior.intervention.description': 'Description',
'behavior.intervention.description_placeholder': 'Describe the intervention…',
'behavior.intervention.status': 'Status',
'behavior.intervention.status.planned': 'Planned',
'behavior.intervention.status.in_progress': 'In Progress',
'behavior.intervention.status.completed': 'Completed',
'behavior.intervention.status.cancelled': 'Cancelled',
'behavior.intervention.assigned_to': 'Assigned To',
'behavior.intervention.assigned_to_placeholder': 'Select a teacher',
'behavior.intervention.start_date': 'Start Date',
'behavior.intervention.end_date': 'End Date',
'behavior.intervention.outcome': 'Outcome',
'behavior.intervention.outcome_placeholder': 'Outcome of the intervention…',
'behavior.intervention.linked_incident': 'Linked Incident',
'behavior.intervention.no_incident': 'No incident linked',
'behavior.intervention.effectiveness': 'Effectiveness',
'behavior.intervention.progress': 'Progress',
'behavior.intervention.no_interventions': 'No interventions yet',
'behavior.intervention.no_interventions_desc': 'Create an intervention to support behavior improvement.',
'behavior.intervention.saved': 'Intervention saved',
'behavior.intervention.deleted': 'Intervention deleted',
'behavior.intervention.error_save': 'Error saving intervention',
'behavior.intervention.error_delete': 'Error deleting intervention',
'behavior.intervention.required_type': 'Please select a type',
'behavior.intervention.required_description': 'Please enter a description',
'behavior.intervention.filter_status': 'Filter by status',
'behavior.intervention.all_statuses': 'All statuses',
'behavior.intervention.filter_type': 'Filter by type',
'behavior.intervention.all_types': 'All types',
// Audit Log Enhancement
'audit.entity_type': 'Entity Type',
'audit.entity_type_filter': 'Filter by type',
'audit.all_entity_types': 'All types',
'audit.user_filter': 'Filter by user',
'audit.all_users': 'All users',
'audit.changes': 'Changes',
'audit.before': 'Before',
'audit.after': 'After',
'audit.detail': 'Detail View',
'audit.export_csv': 'Export as CSV',
'audit.page': 'Page',
'audit.of': 'of',
'audit.entries': 'entries',
'audit.previous': 'Previous',
'audit.next': 'Next',
'audit.no_changes': 'No change details available',
'audit.ip_address': 'IP Address',
'audit.user_agent': 'User Agent',
// Curriculum Coverage
'nav.coverage': 'Coverage',
'coverage.title': 'Curriculum Coverage',
@@ -4653,6 +4938,117 @@ const en: TranslationDict = {
'performance.title': 'Leistung',
'performance.fps': 'Bilder pro Sekunde',
'mobile.optimized': 'Für mobile Geräte optimiert',
// ── Report Scheduling (Task 13) ──
'schedules.title': 'Report Scheduling',
'schedules.frequency': 'Frequency',
'schedules.weekly': 'Weekly',
'schedules.monthly': 'Monthly',
'schedules.quarterly': 'Quarterly',
'schedules.end_of_term': 'End of Term',
'schedules.next_run': 'Next Run',
'schedules.last_run': 'Last Run',
'schedules.manual_run': 'Run Manually',
'schedules.recipients': 'Recipients',
'schedules.include_students': 'Include Students',
'schedules.include_grades': 'Include Grades',
'schedules.include_attendance': 'Include Attendance',
'schedules.include_behavior': 'Include Behavior',
'schedules.include_competencies': 'Include Competencies',
'schedules.create_schedule': 'Create Schedule',
'schedules.edit_schedule': 'Edit Schedule',
'schedules.delete_schedule': 'Delete Schedule',
'schedules.active': 'Active',
'schedules.inactive': 'Inactive',
'schedules.no_schedules': 'No report schedules found',
'schedules.no_schedules_desc': 'Create your first report schedule.',
'schedules.running': 'Running...',
'schedules.run_success': 'Report generated successfully',
'schedules.run_error': 'Error generating report',
'schedules.day_of_week': 'Day of Week',
'schedules.day_of_month': 'Day of Month',
'schedules.month_of_year': 'Month',
'schedules.template': 'Template',
'schedules.all_classes': 'All Classes',
'schedules.timeline': 'Timeline',
'schedules.confirm_delete': 'Delete schedule?',
'schedules.confirm_delete_desc': 'This report schedule will be permanently deleted.',
'schedules.confirm_run': 'Run manually?',
'schedules.confirm_run_desc': 'The report will be generated immediately and sent to recipients.',
'schedules.toggle_active': 'Activate/Deactivate',
// ── School District (Task 13) ──
'district.title': 'School District',
'district.schools': 'Schools',
'district.comparison': 'School Comparison',
'district.analytics': 'District Analytics',
'district.create': 'Create District',
'district.edit': 'Edit District',
'district.delete': 'Delete District',
'district.assign_school': 'Assign School',
'district.unassign_school': 'Remove School',
'district.name': 'Name',
'district.code': 'District Code',
'district.region': 'Region',
'district.country': 'Country',
'district.admin_email': 'Admin Email',
'district.no_districts': 'No school districts found',
'district.no_districts_desc': 'Create your first school district.',
'district.school_count': 'School Count',
'district.student_count': 'Student Count',
'district.confirm_delete': 'Delete district?',
'district.confirm_delete_desc': 'This district will be deleted. Associated schools will remain.',
'district.top_performing': 'Top Performing',
'district.needs_attention': 'Needs Attention',
'district.avg_attendance': 'Average Attendance',
'district.competency_coverage': 'Competency Coverage',
'district.no_schools_assigned': 'No schools assigned',
'district.select_school': 'Select School',
'district.cross_school_comparison': 'Cross-School Comparison',
'district.total_students': 'Total Students',
'district.total_classes': 'Total Classes',
'district.total_teachers': 'Total Teachers',
// PWA / Offline
'pwa.offline': 'You are offline',
'pwa.offline_desc': 'Your data is being loaded from cache.',
'pwa.back_online': 'Back online',
'pwa.back_online_desc': 'Your connection has been restored.',
'pwa.cached_data': 'Cached data',
'pwa.install_app': 'Install App',
'pwa.install_app_desc': 'Install CompetenceTrack as an app on your device.',
'pwa.install': 'Install',
'pwa.installed': 'App installed',
'pwa.installed_desc': 'CompetenceTrack has been successfully installed as an app.',
'pwa.install_accepted': 'Installation started',
'pwa.update_available': 'Update available',
'pwa.update_available_desc': 'A new version is available. Reload the page to update.',
'pwa.reload': 'Reload',
'pwa.offline_mode': 'Offline Mode',
'pwa.pending_sync': '{count} pending synchronizations',
'pwa.sync_complete': 'Sync complete',
'pwa.sync_complete_desc': '{count} changes were successfully synchronized.',
'pwa.notebook_cached': 'Available offline',
'pwa.notebook_cached_desc': 'This notebook is saved for offline use.',
'pwa.cache_notebook': 'Save offline',
'pwa.cache_notebook_desc': 'Save notebook for offline access.',
'pwa.cached_at': 'Cached at',
'pwa.queued_edits': 'Pending changes',
'pwa.queued_edits_desc': '{count} changes will be synced when you are back online.',
// Rate Limiting
'rate_limit.title': 'Too many requests',
'rate_limit.description': 'Please wait {retryAfter} before trying again.',
'rate_limit.status_title': 'Rate Limit Status',
'rate_limit.active_entries': 'Active entries',
'rate_limit.top_consumers': 'Top consumers',
'rate_limit.requests': 'requests',
'rate_limit.no_active': 'No active rate limits',
'rate_limit.unavailable': 'Rate limit status unavailable',
'rate_limit.auth_limit': 'Auth limit: 5/min',
'rate_limit.data_limit': 'Data limit: 60/min',
'rate_limit.write_limit': 'Write limit: 30/min',
'rate_limit.heavy_limit': 'Heavy limit: 10/5min',
};
const translations: Record<string, TranslationDict> = { de, en };
+257
View File
@@ -0,0 +1,257 @@
// CompetenceTrack — Offline Cache for Notebook Data
// Stores notebook data in localStorage for offline access
const NOTEBOOK_CACHE_KEY = 'ct_notebook_cache';
const NOTEBOOK_CACHE_TIMESTAMP = 'ct_notebook_cache_ts';
const NOTEBOOK_EDITS_QUEUE = 'ct_notebook_edits_queue';
interface CachedNotebook {
id: string;
title: string;
notebookType: string;
coverColor: string;
schoolId: string;
ownerId: string;
isArchived: boolean;
pages: CachedPage[];
cachedAt: number;
}
interface CachedPage {
id: string;
title: string;
textContent: string;
drawingData: string | null;
background: string;
pageNumber: number;
}
interface QueuedEdit {
id: string;
notebookId: string;
pageId: string;
field: string;
value: string;
timestamp: number;
retries: number;
}
// ─── Save notebook data to localStorage ───────────────────────────────
export function cacheNotebook(notebook: {
id: string;
title: string;
notebookType: string;
coverColor: string;
schoolId: string;
ownerId: string;
isArchived: boolean;
pages: Array<{
id: string;
title: string;
textContent: string;
drawingData: string | null;
background: string;
pageNumber: number;
}>;
}): void {
try {
const cache: Record<string, CachedNotebook> = JSON.parse(
localStorage.getItem(NOTEBOOK_CACHE_KEY) || '{}'
);
cache[notebook.id] = {
...notebook,
cachedAt: Date.now(),
};
localStorage.setItem(NOTEBOOK_CACHE_KEY, JSON.stringify(cache));
localStorage.setItem(NOTEBOOK_CACHE_TIMESTAMP, String(Date.now()));
} catch (error) {
console.warn('[OfflineCache] Failed to cache notebook:', error);
}
}
// ─── Retrieve cached notebook ──────────────────────────────────────────
export function getCachedNotebook(id: string): CachedNotebook | null {
try {
const cache: Record<string, CachedNotebook> = JSON.parse(
localStorage.getItem(NOTEBOOK_CACHE_KEY) || '{}'
);
return cache[id] || null;
} catch {
return null;
}
}
// ─── Get all cached notebooks ──────────────────────────────────────────
export function getAllCachedNotebooks(): CachedNotebook[] {
try {
const cache: Record<string, CachedNotebook> = JSON.parse(
localStorage.getItem(NOTEBOOK_CACHE_KEY) || '{}'
);
return Object.values(cache).sort((a, b) => b.cachedAt - a.cachedAt);
} catch {
return [];
}
}
// ─── Remove a cached notebook ──────────────────────────────────────────
export function removeCachedNotebook(id: string): void {
try {
const cache: Record<string, CachedNotebook> = JSON.parse(
localStorage.getItem(NOTEBOOK_CACHE_KEY) || '{}'
);
delete cache[id];
localStorage.setItem(NOTEBOOK_CACHE_KEY, JSON.stringify(cache));
} catch {
// ignore
}
}
// ─── Check if a notebook is cached ──────────────────────────────────────
export function isNotebookCached(id: string): boolean {
try {
const cache: Record<string, CachedNotebook> = JSON.parse(
localStorage.getItem(NOTEBOOK_CACHE_KEY) || '{}'
);
return !!cache[id];
} catch {
return false;
}
}
// ─── Get cache timestamp ──────────────────────────────────────────────
export function getCacheTimestamp(): number {
try {
return Number(localStorage.getItem(NOTEBOOK_CACHE_TIMESTAMP) || '0');
} catch {
return 0;
}
}
// ─── Queue an edit for sync when back online ────────────────────────────
export function queueNotebookEdit(
notebookId: string,
pageId: string,
field: string,
value: string
): void {
try {
const queue: QueuedEdit[] = JSON.parse(
localStorage.getItem(NOTEBOOK_EDITS_QUEUE) || '[]'
);
queue.push({
id: `edit_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
notebookId,
pageId,
field,
value,
timestamp: Date.now(),
retries: 0,
});
localStorage.setItem(NOTEBOOK_EDITS_QUEUE, JSON.stringify(queue));
// Also update the cached notebook locally
const cache: Record<string, CachedNotebook> = JSON.parse(
localStorage.getItem(NOTEBOOK_CACHE_KEY) || '{}'
);
const cached = cache[notebookId];
if (cached) {
const page = cached.pages.find((p) => p.id === pageId);
if (page) {
(page as Record<string, unknown>)[field] = value;
localStorage.setItem(NOTEBOOK_CACHE_KEY, JSON.stringify(cache));
}
}
} catch (error) {
console.warn('[OfflineCache] Failed to queue edit:', error);
}
}
// ─── Get queued edits ──────────────────────────────────────────────────
export function getQueuedEdits(): QueuedEdit[] {
try {
return JSON.parse(localStorage.getItem(NOTEBOOK_EDITS_QUEUE) || '[]');
} catch {
return [];
}
}
// ─── Replay queued edits when back online ───────────────────────────────
export async function replayQueuedEdits(): Promise<{ success: number; failed: number }> {
const queue = getQueuedEdits();
let success = 0;
let failed = 0;
for (const edit of queue) {
try {
const res = await fetch(`/api/notebooks/${edit.notebookId}/pages/${edit.pageId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [edit.field]: edit.value }),
});
if (res.ok) {
removeQueuedEdit(edit.id);
success++;
} else {
failed++;
}
} catch {
failed++;
}
}
return { success, failed };
}
// ─── Remove a queued edit ──────────────────────────────────────────────
export function removeQueuedEdit(id: string): void {
try {
const queue: QueuedEdit[] = JSON.parse(
localStorage.getItem(NOTEBOOK_EDITS_QUEUE) || '[]'
);
const filtered = queue.filter((e) => e.id !== id);
localStorage.setItem(NOTEBOOK_EDITS_QUEUE, JSON.stringify(filtered));
} catch {
// ignore
}
}
// ─── Clear all queued edits ────────────────────────────────────────────
export function clearQueuedEdits(): void {
try {
localStorage.removeItem(NOTEBOOK_EDITS_QUEUE);
} catch {
// ignore
}
}
// ─── Clear all notebook cache ──────────────────────────────────────────
export function clearNotebookCache(): void {
try {
localStorage.removeItem(NOTEBOOK_CACHE_KEY);
localStorage.removeItem(NOTEBOOK_CACHE_TIMESTAMP);
} catch {
// ignore
}
}
// ─── Get offline status ──────────────────────────────────────────────
export function isOffline(): boolean {
return typeof navigator !== 'undefined' && !navigator.onLine;
}
+218
View File
@@ -0,0 +1,218 @@
// CompetenceTrack — API Rate Limiting Middleware
// In-memory rate limiting with configurable limits per endpoint
interface RateLimitEntry {
count: number;
resetTime: number;
}
// ─── Rate Limit Store ──────────────────────────────────────────────────
const rateLimitStore = new Map<string, RateLimitEntry>();
// Cleanup expired entries every 60 seconds
setInterval(() => {
const now = Date.now();
for (const [key, entry] of rateLimitStore.entries()) {
if (now > entry.resetTime) {
rateLimitStore.delete(key);
}
}
}, 60 * 1000);
// ─── Preset Limits ────────────────────────────────────────────────────
export const RATE_LIMITS = {
// Auth endpoints: 5 requests per minute (strict)
auth: { limit: 5, windowMs: 60 * 1000 },
// Data read endpoints: 60 requests per minute (generous)
dataRead: { limit: 60, windowMs: 60 * 1000 },
// Data write endpoints (POST, PUT, DELETE): 30 requests per minute
dataWrite: { limit: 30, windowMs: 60 * 1000 },
// Heavy operations (export, PDF generation): 10 requests per 5 minutes
heavy: { limit: 10, windowMs: 5 * 60 * 1000 },
// Default: 30 requests per minute
default: { limit: 30, windowMs: 60 * 1000 },
} as const;
// ─── Rate Limit Check ─────────────────────────────────────────────────
interface RateLimitResult {
allowed: boolean;
limit: number;
remaining: number;
resetTime: number;
retryAfterMs?: number;
}
export function checkRateLimit(
key: string,
limit: number,
windowMs: number
): RateLimitResult {
const now = Date.now();
const entry = rateLimitStore.get(key);
// If no entry or expired window, create new entry
if (!entry || now > entry.resetTime) {
const resetTime = now + windowMs;
rateLimitStore.set(key, { count: 1, resetTime });
return {
allowed: true,
limit,
remaining: limit - 1,
resetTime,
};
}
// If within window, increment count
if (entry.count < limit) {
entry.count += 1;
return {
allowed: true,
limit,
remaining: limit - entry.count,
resetTime: entry.resetTime,
};
}
// Rate limit exceeded
const retryAfterMs = entry.resetTime - now;
return {
allowed: false,
limit,
remaining: 0,
resetTime: entry.resetTime,
retryAfterMs,
};
}
// ─── Get Rate Limit Key ────────────────────────────────────────────────
export function getRateLimitKey(
request: Request,
userId?: string
): string {
// Get IP from headers or fallback
const forwarded = request.headers.get('x-forwarded-for');
const ip = forwarded?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
'unknown';
// Combine IP + userId for unique key
if (userId) {
return `${ip}:${userId}`;
}
return ip;
}
// ─── Rate Limit Headers ───────────────────────────────────────────────
export function getRateLimitHeaders(result: RateLimitResult): Headers {
const headers = new Headers();
headers.set('X-RateLimit-Limit', String(result.limit));
headers.set('X-RateLimit-Remaining', String(result.remaining));
headers.set('X-RateLimit-Reset', String(Math.ceil(result.resetTime / 1000)));
if (result.retryAfterMs) {
headers.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)));
}
return headers;
}
// ─── Rate Limit Response ──────────────────────────────────────────────
export function createRateLimitResponse(result: RateLimitResult): {
status: number;
body: { error: string; retryAfter: number; limit: number };
headers: Headers;
} {
return {
status: 429,
body: {
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((result.retryAfterMs || 0) / 1000),
limit: result.limit,
},
headers: getRateLimitHeaders(result),
};
}
// ─── withRateLimit Wrapper ────────────────────────────────────────────
import { NextResponse } from 'next/server';
type RouteHandler = (request: Request, context?: { params: Promise<Record<string, string>> }) => Promise<NextResponse>;
export function withRateLimit(
handler: RouteHandler,
limitPreset: keyof typeof RATE_LIMITS | { limit: number; windowMs: number }
): RouteHandler {
return async (request: Request, context?: { params: Promise<Record<string, string>> }) => {
const { limit, windowMs } = typeof limitPreset === 'string'
? RATE_LIMITS[limitPreset]
: limitPreset;
// Get user ID from session if available (for auth routes, we skip this)
const key = getRateLimitKey(request);
const result = checkRateLimit(key, limit, windowMs);
if (!result.allowed) {
const rateLimitResponse = createRateLimitResponse(result);
return NextResponse.json(rateLimitResponse.body, {
status: rateLimitResponse.status,
headers: rateLimitResponse.headers,
});
}
// Execute the handler
const response = await handler(request, context);
// Add rate limit headers to successful responses
const rateLimitHeaders = getRateLimitHeaders(result);
for (const [headerKey, headerValue] of rateLimitHeaders.entries()) {
response.headers.set(headerKey, headerValue);
}
return response;
};
}
// ─── Rate Limit Stats (for admin monitoring) ──────────────────────────
export interface RateLimitStats {
totalEntries: number;
activeKeys: string[];
topConsumers: Array<{ key: string; count: number; resetTime: number }>;
}
export function getRateLimitStats(): RateLimitStats {
const entries = Array.from(rateLimitStore.entries());
const now = Date.now();
// Filter out expired entries
const activeEntries = entries.filter(([, entry]) => now <= entry.resetTime);
// Sort by count (most active first)
const sorted = activeEntries.sort((a, b) => b[1].count - a[1].count);
return {
totalEntries: activeEntries.length,
activeKeys: sorted.map(([key]) => key),
topConsumers: sorted.slice(0, 20).map(([key, entry]) => ({
key,
count: entry.count,
resetTime: entry.resetTime,
})),
};
}
// ─── Clear all rate limit entries ─────────────────────────────────────
export function clearRateLimitEntries(): void {
rateLimitStore.clear();
}