Round 13: PWA Offline Mode, Rate Limiting, Behavior Analytics, Interventions, Audit Logging
This commit is contained in:
1 parent
ceea8fb591
commit
ff4dd670d4
33 files changed
+6327
-509
No files matched your search
+279
-2
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user