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
@@ -42,6 +42,10 @@ model School {
|
|||||||
parentStudentLinks ParentStudentLink[]
|
parentStudentLinks ParentStudentLink[]
|
||||||
curriculumStandards CurriculumStandard[]
|
curriculumStandards CurriculumStandard[]
|
||||||
backups Backup[]
|
backups Backup[]
|
||||||
|
behaviorInterventions BehaviorIntervention[]
|
||||||
|
reportSchedules ReportSchedule[]
|
||||||
|
district SchoolDistrict? @relation(fields: [districtId], references: [id])
|
||||||
|
districtId String?
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -79,6 +83,7 @@ model User {
|
|||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
calendarEvents CalendarEvent[]
|
calendarEvents CalendarEvent[]
|
||||||
parentLinks ParentStudentLink[]
|
parentLinks ParentStudentLink[]
|
||||||
|
assignedInterventions BehaviorIntervention[] @relation("InterventionAssignee")
|
||||||
}
|
}
|
||||||
|
|
||||||
model SchoolYear {
|
model SchoolYear {
|
||||||
@@ -124,6 +129,7 @@ model ClassGroup {
|
|||||||
notebooks Notebook[]
|
notebooks Notebook[]
|
||||||
drawings Drawing[]
|
drawings Drawing[]
|
||||||
calendarEvents CalendarEvent[]
|
calendarEvents CalendarEvent[]
|
||||||
|
reportSchedules ReportSchedule[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model ClassGroupTeacher {
|
model ClassGroupTeacher {
|
||||||
@@ -160,6 +166,7 @@ model Student {
|
|||||||
parentContacts ParentContact[]
|
parentContacts ParentContact[]
|
||||||
parentMessages ParentMessage[]
|
parentMessages ParentMessage[]
|
||||||
behaviorIncidents BehaviorIncident[]
|
behaviorIncidents BehaviorIncident[]
|
||||||
|
behaviorInterventions BehaviorIntervention[]
|
||||||
parentStudentLinks ParentStudentLink[]
|
parentStudentLinks ParentStudentLink[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,6 +434,9 @@ model AuditLog {
|
|||||||
action String
|
action String
|
||||||
entityType String
|
entityType String
|
||||||
entityId String?
|
entityId String?
|
||||||
|
changes String? // JSON: { before: {...}, after: {...} }
|
||||||
|
ipAddress String?
|
||||||
|
userAgent String?
|
||||||
timestamp DateTime @default(now())
|
timestamp DateTime @default(now())
|
||||||
metadata String? // JSON
|
metadata String? // JSON
|
||||||
|
|
||||||
@@ -630,6 +640,8 @@ model BehaviorIncident {
|
|||||||
@@index([classGroupId, date])
|
@@index([classGroupId, date])
|
||||||
@@index([schoolId, date])
|
@@index([schoolId, date])
|
||||||
@@index([teacherId])
|
@@index([teacherId])
|
||||||
|
|
||||||
|
interventions BehaviorIntervention[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Rubric Library ────────────────────────────────────────────────
|
// ─── Rubric Library ────────────────────────────────────────────────
|
||||||
@@ -964,3 +976,82 @@ model Backup {
|
|||||||
|
|
||||||
@@map("backups")
|
@@map("backups")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Behavior Interventions ─────────────────────────────────────────
|
||||||
|
|
||||||
|
model BehaviorIntervention {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
schoolId String
|
||||||
|
studentId String
|
||||||
|
incidentId String?
|
||||||
|
type String // "warning", "meeting", "action_plan", "support", "parent_contact"
|
||||||
|
description String
|
||||||
|
status String @default("planned") // "planned", "in_progress", "completed", "cancelled"
|
||||||
|
assignedTo String?
|
||||||
|
startDate DateTime?
|
||||||
|
endDate DateTime?
|
||||||
|
outcome String?
|
||||||
|
isDemo Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
school School @relation(fields: [schoolId], references: [id])
|
||||||
|
student Student @relation(fields: [studentId], references: [id])
|
||||||
|
incident BehaviorIncident? @relation(fields: [incidentId], references: [id])
|
||||||
|
assignedUser User? @relation("InterventionAssignee", fields: [assignedTo], references: [id])
|
||||||
|
|
||||||
|
@@index([schoolId, studentId])
|
||||||
|
@@map("behavior_interventions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Report Scheduling ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
model ReportSchedule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
schoolId String
|
||||||
|
classGroupId String?
|
||||||
|
template String // "short", "full", "custom"
|
||||||
|
frequency String // "weekly", "monthly", "quarterly", "end_of_term"
|
||||||
|
dayOfWeek Int? // 0-6 for weekly
|
||||||
|
dayOfMonth Int? // 1-31 for monthly
|
||||||
|
monthOfYear Int? // 1-12 for quarterly
|
||||||
|
recipients String? // JSON array of userIds
|
||||||
|
includeStudents Boolean @default(true)
|
||||||
|
includeGrades Boolean @default(true)
|
||||||
|
includeAttendance Boolean @default(true)
|
||||||
|
includeBehavior Boolean @default(false)
|
||||||
|
includeCompetencies Boolean @default(true)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
lastRunAt DateTime?
|
||||||
|
nextRunAt DateTime?
|
||||||
|
isDemo Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
school School @relation(fields: [schoolId], references: [id])
|
||||||
|
classGroup ClassGroup? @relation(fields: [classGroupId], references: [id])
|
||||||
|
|
||||||
|
@@index([schoolId, isActive])
|
||||||
|
@@map("report_schedules")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── School District ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
model SchoolDistrict {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
code String? // district code
|
||||||
|
region String?
|
||||||
|
country String @default("DE")
|
||||||
|
adminEmail String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
schools School[]
|
||||||
|
|
||||||
|
@@map("school_districts")
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "CompetenceTrack",
|
||||||
|
"short_name": "CompTrack",
|
||||||
|
"description": "Kompetenz- und Benotungsplattform für Schulen",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#10b981",
|
||||||
|
"orientation": "any",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["education", "productivity"],
|
||||||
|
"lang": "de",
|
||||||
|
"dir": "ltr"
|
||||||
|
}
|
||||||
+300
@@ -0,0 +1,300 @@
|
|||||||
|
// CompetenceTrack — Service Worker for PWA Offline Support
|
||||||
|
const CACHE_NAME = 'competencetrack-v1';
|
||||||
|
const STATIC_CACHE = 'competencetrack-static-v1';
|
||||||
|
const DATA_CACHE = 'competencetrack-data-v1';
|
||||||
|
const OFFLINE_CACHE = 'competencetrack-offline-v1';
|
||||||
|
|
||||||
|
// Static assets to cache on install
|
||||||
|
const STATIC_ASSETS = [
|
||||||
|
'/',
|
||||||
|
'/manifest.json',
|
||||||
|
'/logo.svg',
|
||||||
|
'/robots.txt',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install event — cache static assets and offline fallback
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
Promise.all([
|
||||||
|
caches.open(STATIC_CACHE).then((cache) => {
|
||||||
|
return cache.addAll(STATIC_ASSETS).catch((err) => {
|
||||||
|
console.warn('[SW] Some static assets failed to cache:', err);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
caches.open(OFFLINE_CACHE).then((cache) => {
|
||||||
|
return cache.add('/').catch(() => {
|
||||||
|
// Fallback: just create the cache
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
]).then(() => {
|
||||||
|
console.log('[SW] Installed and caches populated');
|
||||||
|
return self.skipWaiting();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate event — clean up old caches
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) => {
|
||||||
|
return Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => key !== CACHE_NAME && key !== STATIC_CACHE && key !== DATA_CACHE && key !== OFFLINE_CACHE)
|
||||||
|
.map((key) => caches.delete(key))
|
||||||
|
);
|
||||||
|
}).then(() => {
|
||||||
|
console.log('[SW] Activated and old caches cleaned');
|
||||||
|
return self.clients.claim();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch event — routing strategy
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Skip non-GET requests (POST, PUT, DELETE) — handle with background sync
|
||||||
|
if (request.method !== 'GET') {
|
||||||
|
// Queue write operations for background sync
|
||||||
|
if (navigator.onLine === false) {
|
||||||
|
event.respondWith(
|
||||||
|
new Response(JSON.stringify({ queued: true, offline: true }), {
|
||||||
|
status: 202,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Store the request for later sync
|
||||||
|
storeRequestForSync(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return; // Let non-GET requests go through normally when online
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip chrome-extension and other non-http requests
|
||||||
|
if (!url.protocol.startsWith('http')) return;
|
||||||
|
|
||||||
|
// API data requests — NetworkFirst strategy
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
event.respondWith(networkFirstStrategy(request, DATA_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static assets (CSS, JS, images) — CacheFirst strategy
|
||||||
|
if (
|
||||||
|
url.pathname.endsWith('.css') ||
|
||||||
|
url.pathname.endsWith('.js') ||
|
||||||
|
url.pathname.endsWith('.png') ||
|
||||||
|
url.pathname.endsWith('.jpg') ||
|
||||||
|
url.pathname.endsWith('.svg') ||
|
||||||
|
url.pathname.endsWith('.ico') ||
|
||||||
|
url.pathname.endsWith('.woff2') ||
|
||||||
|
url.pathname.endsWith('.woff')
|
||||||
|
) {
|
||||||
|
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation requests — NetworkFirst with offline fallback
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
event.respondWith(networkFirstWithOfflineFallback(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: NetworkFirst
|
||||||
|
event.respondWith(networkFirstStrategy(request, CACHE_NAME));
|
||||||
|
});
|
||||||
|
|
||||||
|
// NetworkFirst strategy — try network, fall back to cache
|
||||||
|
async function networkFirstStrategy(request, cacheName) {
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
if (networkResponse.ok) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
// Return offline response for API calls
|
||||||
|
if (new URL(request.url).pathname.startsWith('/api/')) {
|
||||||
|
return new Response(JSON.stringify({ error: 'offline', message: 'No cached data available' }), {
|
||||||
|
status: 503,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response('Offline', { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheFirst strategy — try cache, fall back to network
|
||||||
|
async function cacheFirstStrategy(request, cacheName) {
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
if (networkResponse.ok) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
// No cache and no network
|
||||||
|
return new Response('Offline', { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetworkFirst with offline fallback page
|
||||||
|
async function networkFirstWithOfflineFallback(request) {
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
if (networkResponse.ok) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
// Fall back to cached root page
|
||||||
|
const fallbackResponse = await caches.match('/');
|
||||||
|
if (fallbackResponse) {
|
||||||
|
return fallbackResponse;
|
||||||
|
}
|
||||||
|
return new Response('Offline — Please check your connection', {
|
||||||
|
status: 503,
|
||||||
|
headers: { 'Content-Type': 'text/html' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store failed requests for background sync
|
||||||
|
async function storeRequestForSync(request) {
|
||||||
|
const db = await openIndexedDB();
|
||||||
|
const tx = db.transaction('pending-requests', 'readwrite');
|
||||||
|
const store = tx.objectStore('pending-requests');
|
||||||
|
const requestData = {
|
||||||
|
url: request.url,
|
||||||
|
method: request.method,
|
||||||
|
headers: Object.fromEntries(request.headers.entries()),
|
||||||
|
body: await request.text().catch(() => ''),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
store.add(requestData);
|
||||||
|
await tx.done;
|
||||||
|
|
||||||
|
// Register for background sync
|
||||||
|
self.registration.sync.register('competencetrack-sync');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open IndexedDB for storing pending requests
|
||||||
|
function openIndexedDB() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open('competencetrack-sync-db', 1);
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result;
|
||||||
|
if (!db.objectStoreNames.contains('pending-requests')) {
|
||||||
|
db.createObjectStore('pending-requests', { keyPath: 'timestamp' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background sync event — replay queued requests when back online
|
||||||
|
self.addEventListener('sync', (event) => {
|
||||||
|
if (event.tag === 'competencetrack-sync') {
|
||||||
|
event.waitUntil(replayPendingRequests());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function replayPendingRequests() {
|
||||||
|
const db = await openIndexedDB();
|
||||||
|
const tx = db.transaction('pending-requests', 'readwrite');
|
||||||
|
const store = tx.objectStore('pending-requests');
|
||||||
|
const allRequests = await store.getAll();
|
||||||
|
|
||||||
|
for (const reqData of allRequests) {
|
||||||
|
try {
|
||||||
|
const headers = new Headers(reqData.headers);
|
||||||
|
const response = await fetch(reqData.url, {
|
||||||
|
method: reqData.method,
|
||||||
|
headers,
|
||||||
|
body: reqData.method !== 'GET' ? reqData.body : undefined,
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
store.delete(reqData.timestamp);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[SW] Failed to replay request:', error);
|
||||||
|
// Keep the request in the store for next sync attempt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push event — show notification (if push notifications are configured)
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
if (!event.data) return;
|
||||||
|
const data = event.data.json();
|
||||||
|
const options = {
|
||||||
|
body: data.message || '',
|
||||||
|
icon: '/icon-192.png',
|
||||||
|
badge: '/icon-192.png',
|
||||||
|
vibrate: [100, 50, 100],
|
||||||
|
data: {
|
||||||
|
url: data.url || '/',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title || 'CompetenceTrack', options)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notification click — open the app
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const url = event.notification.data?.url || '/';
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: 'window' }).then((clients) => {
|
||||||
|
// Focus existing window if available
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.url.includes(url) && 'focus' in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Open new window
|
||||||
|
return self.clients.openWindow(url);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Message event — handle messages from the app
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
if (event.data && event.data.type === 'CLEAR_CACHES') {
|
||||||
|
caches.keys().then((keys) => {
|
||||||
|
Promise.all(keys.map((key) => caches.delete(key))).then(() => {
|
||||||
|
console.log('[SW] All caches cleared');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (event.data && event.data.type === 'CACHE_URLS') {
|
||||||
|
const urls = event.data.urls || [];
|
||||||
|
caches.open(STATIC_CACHE).then((cache) => {
|
||||||
|
cache.addAll(urls).catch((err) => {
|
||||||
|
console.warn('[SW] Failed to cache URLs:', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -12,8 +12,13 @@ export async function GET(request: Request) {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const schoolId = searchParams.get('schoolId');
|
const schoolId = searchParams.get('schoolId');
|
||||||
const action = searchParams.get('action');
|
const action = searchParams.get('action');
|
||||||
|
const entityType = searchParams.get('entityType');
|
||||||
|
const userId = searchParams.get('userId');
|
||||||
const startDate = searchParams.get('startDate');
|
const startDate = searchParams.get('startDate');
|
||||||
const endDate = searchParams.get('endDate');
|
const endDate = searchParams.get('endDate');
|
||||||
|
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||||
|
const pageSize = parseInt(searchParams.get('pageSize') || '50', 10);
|
||||||
|
const exportCsv = searchParams.get('export') === 'csv';
|
||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
|
|
||||||
@@ -25,6 +30,8 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action) where.action = action;
|
if (action) where.action = action;
|
||||||
|
if (entityType) where.entityType = entityType;
|
||||||
|
if (userId) where.userId = userId;
|
||||||
|
|
||||||
if (startDate || endDate) {
|
if (startDate || endDate) {
|
||||||
where.timestamp = {
|
where.timestamp = {
|
||||||
@@ -33,10 +40,52 @@ export async function GET(request: Request) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSV export
|
||||||
|
if (exportCsv) {
|
||||||
|
const entries = await db.auditLog.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { timestamp: 'desc' },
|
||||||
|
take: 10000,
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: { id: true, firstName: true, lastName: true, email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const header = 'Timestamp,User,Action,Entity Type,Entity ID,Changes,IP Address,User Agent,Metadata';
|
||||||
|
const rows = entries.map((e) => {
|
||||||
|
const userName = e.user ? `${e.user.firstName} ${e.user.lastName}` : 'System';
|
||||||
|
const escape = (s: string | null) => (s ? `"${s.replace(/"/g, '""')}"` : '""');
|
||||||
|
return [
|
||||||
|
new Date(e.timestamp).toISOString(),
|
||||||
|
escape(userName),
|
||||||
|
escape(e.action),
|
||||||
|
escape(e.entityType),
|
||||||
|
escape(e.entityId),
|
||||||
|
escape(e.changes),
|
||||||
|
escape(e.ipAddress),
|
||||||
|
escape(e.userAgent),
|
||||||
|
escape(e.metadata),
|
||||||
|
].join(',');
|
||||||
|
});
|
||||||
|
|
||||||
|
const csv = [header, ...rows].join('\n');
|
||||||
|
return new Response(csv, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/csv; charset=utf-8',
|
||||||
|
'Content-Disposition': 'attachment; filename="audit-log.csv"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular paginated response
|
||||||
|
const total = await db.auditLog.count({ where });
|
||||||
const entries = await db.auditLog.findMany({
|
const entries = await db.auditLog.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { timestamp: 'desc' },
|
orderBy: { timestamp: 'desc' },
|
||||||
take: 200,
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
select: { id: true, firstName: true, lastName: true },
|
select: { id: true, firstName: true, lastName: true },
|
||||||
@@ -44,7 +93,15 @@ export async function GET(request: Request) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(entries);
|
return NextResponse.json({
|
||||||
|
entries,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('AuditLog GET error:', error);
|
console.error('AuditLog GET error:', error);
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getSession,
|
getSession,
|
||||||
clearSession,
|
clearSession,
|
||||||
} from '@/lib/auth';
|
} from '@/lib/auth';
|
||||||
|
import { withRateLimit, checkRateLimit, getRateLimitKey, getRateLimitHeaders, createRateLimitResponse } from '@/lib/rate-limit';
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
@@ -23,7 +24,7 @@ const registerSchema = z.object({
|
|||||||
role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN']).default('TEACHER'),
|
role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN']).default('TEACHER'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export const POST = withRateLimit(async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { action } = body;
|
const { action } = body;
|
||||||
@@ -138,15 +139,33 @@ export async function POST(request: Request) {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}, 'auth');
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
// Apply rate limit for auth check endpoint
|
||||||
|
const key = getRateLimitKey(request);
|
||||||
|
const result = checkRateLimit(key, 60, 60 * 1000); // 60 per minute for GET
|
||||||
|
|
||||||
|
if (!result.allowed) {
|
||||||
|
const rateLimitResponse = createRateLimitResponse(result);
|
||||||
|
return NextResponse.json(rateLimitResponse.body, {
|
||||||
|
status: rateLimitResponse.status,
|
||||||
|
headers: rateLimitResponse.headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
try {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
if (!session || !session.user) {
|
if (!session || !session.user) {
|
||||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
}
|
}
|
||||||
return NextResponse.json(session.user);
|
|
||||||
|
const response = NextResponse.json(session.user);
|
||||||
|
const rateLimitHeaders = getRateLimitHeaders(result);
|
||||||
|
for (const [headerKey, headerValue] of rateLimitHeaders.entries()) {
|
||||||
|
response.headers.set(headerKey, headerValue);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth me error:', error);
|
console.error('Auth me error:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { withRateLimit } from '@/lib/rate-limit';
|
||||||
|
|
||||||
// GET /api/backup — List backups for a school
|
// GET /api/backup — List backups for a school
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
@@ -21,7 +22,7 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/backup — Create a new backup or restore from backup
|
// POST /api/backup — Create a new backup or restore from backup
|
||||||
export async function POST(req: NextRequest) {
|
export const POST = withRateLimit(async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { schoolId, action, backupId, notes } = body;
|
const { schoolId, action, backupId, notes } = body;
|
||||||
@@ -139,7 +140,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
return NextResponse.json({ error: 'Failed to create backup' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to create backup' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}, 'heavy');
|
||||||
|
|
||||||
// DELETE /api/backup — Delete a backup
|
// DELETE /api/backup — Delete a backup
|
||||||
export async function DELETE(req: NextRequest) {
|
export async function DELETE(req: NextRequest) {
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
import { logAudit, extractClientInfo } from '@/lib/audit';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const intervention = await db.behaviorIntervention.findFirst({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
incident: {
|
||||||
|
select: { id: true, description: true, date: true, severity: true },
|
||||||
|
},
|
||||||
|
assignedUser: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!intervention) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not super admin, restrict to user's school
|
||||||
|
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== intervention.schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(intervention);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('BehaviorIntervention GET [id] error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const existing = await db.behaviorIntervention.findFirst({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const {
|
||||||
|
type,
|
||||||
|
description,
|
||||||
|
status: interventionStatus,
|
||||||
|
assignedTo,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
outcome,
|
||||||
|
incidentId,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
const updated = await db.behaviorIntervention.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(type !== undefined && { type }),
|
||||||
|
...(description !== undefined && { description }),
|
||||||
|
...(interventionStatus !== undefined && { status: interventionStatus }),
|
||||||
|
...(assignedTo !== undefined && { assignedTo: assignedTo || null }),
|
||||||
|
...(startDate !== undefined && { startDate: startDate ? new Date(startDate) : null }),
|
||||||
|
...(endDate !== undefined && { endDate: endDate ? new Date(endDate) : null }),
|
||||||
|
...(outcome !== undefined && { outcome }),
|
||||||
|
...(incidentId !== undefined && { incidentId: incidentId || null }),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
incident: {
|
||||||
|
select: { id: true, description: true, date: true, severity: true },
|
||||||
|
},
|
||||||
|
assignedUser: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
const clientInfo = extractClientInfo(request);
|
||||||
|
await logAudit({
|
||||||
|
userId: session.user?.id,
|
||||||
|
schoolId: existing.schoolId,
|
||||||
|
action: 'UPDATE',
|
||||||
|
entityType: 'BehaviorIntervention',
|
||||||
|
entityId: id,
|
||||||
|
changes: { before: existing, after: updated },
|
||||||
|
...clientInfo,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('BehaviorIntervention PUT [id] error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const existing = await db.behaviorIntervention.findFirst({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await db.behaviorIntervention.update({
|
||||||
|
where: { id },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
const clientInfo = extractClientInfo(request);
|
||||||
|
await logAudit({
|
||||||
|
userId: session.user?.id,
|
||||||
|
schoolId: existing.schoolId,
|
||||||
|
action: 'DELETE',
|
||||||
|
entityType: 'BehaviorIntervention',
|
||||||
|
entityId: id,
|
||||||
|
changes: { before: existing },
|
||||||
|
...clientInfo,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('BehaviorIntervention DELETE [id] error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
import { logAudit, extractClientInfo } from '@/lib/audit';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const schoolId = searchParams.get('schoolId');
|
||||||
|
const studentId = searchParams.get('studentId');
|
||||||
|
const incidentId = searchParams.get('incidentId');
|
||||||
|
const type = searchParams.get('type');
|
||||||
|
const status = searchParams.get('status');
|
||||||
|
|
||||||
|
if (!schoolId) {
|
||||||
|
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not super admin, restrict to user's school
|
||||||
|
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {
|
||||||
|
schoolId,
|
||||||
|
deletedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (studentId) where.studentId = studentId;
|
||||||
|
if (incidentId) where.incidentId = incidentId;
|
||||||
|
if (type) where.type = type;
|
||||||
|
if (status) where.status = status;
|
||||||
|
|
||||||
|
const interventions = await db.behaviorIntervention.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
incident: {
|
||||||
|
select: { id: true, description: true, date: true, severity: true },
|
||||||
|
},
|
||||||
|
assignedUser: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(interventions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('BehaviorIntervention GET error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const {
|
||||||
|
schoolId,
|
||||||
|
studentId,
|
||||||
|
incidentId,
|
||||||
|
type,
|
||||||
|
description,
|
||||||
|
status: interventionStatus,
|
||||||
|
assignedTo,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
outcome,
|
||||||
|
isDemo,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!schoolId || !studentId || !type || !description) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'schoolId, studentId, type, and description are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervention = await db.behaviorIntervention.create({
|
||||||
|
data: {
|
||||||
|
schoolId,
|
||||||
|
studentId,
|
||||||
|
incidentId: incidentId || null,
|
||||||
|
type,
|
||||||
|
description,
|
||||||
|
status: interventionStatus || 'planned',
|
||||||
|
assignedTo: assignedTo || null,
|
||||||
|
startDate: startDate ? new Date(startDate) : null,
|
||||||
|
endDate: endDate ? new Date(endDate) : null,
|
||||||
|
outcome: outcome || null,
|
||||||
|
isDemo: isDemo ?? false,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
incident: {
|
||||||
|
select: { id: true, description: true, date: true, severity: true },
|
||||||
|
},
|
||||||
|
assignedUser: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
const clientInfo = extractClientInfo(request);
|
||||||
|
await logAudit({
|
||||||
|
userId: session.user?.id,
|
||||||
|
schoolId,
|
||||||
|
action: 'CREATE',
|
||||||
|
entityType: 'BehaviorIntervention',
|
||||||
|
entityId: intervention.id,
|
||||||
|
changes: { after: intervention },
|
||||||
|
...clientInfo,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(intervention, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('BehaviorIntervention POST error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { getSession } from '@/lib/auth';
|
import { getSession } from '@/lib/auth';
|
||||||
|
import { withRateLimit } from '@/lib/rate-limit';
|
||||||
|
|
||||||
function escapeCsvField(field: string | number | null | undefined): string {
|
function escapeCsvField(field: string | number | null | undefined): string {
|
||||||
if (field === null || field === undefined) return '';
|
if (field === null || field === undefined) return '';
|
||||||
@@ -16,7 +17,7 @@ function toCsvRow(fields: (string | number | null | undefined)[]): string {
|
|||||||
return fields.map(escapeCsvField).join(',');
|
return fields.map(escapeCsvField).join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export const GET = withRateLimit(async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -318,4 +319,4 @@ export async function GET(request: Request) {
|
|||||||
console.error('CSV Export error:', error);
|
console.error('CSV Export error:', error);
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}, 'heavy');
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { getSession } from '@/lib/auth';
|
import { getSession } from '@/lib/auth';
|
||||||
|
import { withRateLimit } from '@/lib/rate-limit';
|
||||||
|
|
||||||
function escapeCsvField(field: string | number | null | undefined): string {
|
function escapeCsvField(field: string | number | null | undefined): string {
|
||||||
if (field === null || field === undefined) return '';
|
if (field === null || field === undefined) return '';
|
||||||
@@ -59,7 +60,7 @@ function parseCsv(text: string): { headers: string[]; rows: ImportRow[] } {
|
|||||||
return { headers, rows };
|
return { headers, rows };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export const POST = withRateLimit(async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -217,7 +218,7 @@ export async function POST(request: Request) {
|
|||||||
console.error('Data Import error:', error);
|
console.error('Data Import error:', error);
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}, 'heavy');
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
// GET /api/districts/[id]
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const district = await db.schoolDistrict.findFirst({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
include: {
|
||||||
|
schools: {
|
||||||
|
where: { deletedAt: null },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
schoolType: true,
|
||||||
|
country: true,
|
||||||
|
_count: {
|
||||||
|
select: { students: true, classGroups: true, users: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!district) {
|
||||||
|
return NextResponse.json({ error: 'District not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(district);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[districts/[id]] GET error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch district' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/districts/[id]
|
||||||
|
export async function PUT(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await req.json();
|
||||||
|
const { name, code, region, country, adminEmail, isActive } = body;
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if (name !== undefined) data.name = name;
|
||||||
|
if (code !== undefined) data.code = code || null;
|
||||||
|
if (region !== undefined) data.region = region || null;
|
||||||
|
if (country !== undefined) data.country = country;
|
||||||
|
if (adminEmail !== undefined) data.adminEmail = adminEmail || null;
|
||||||
|
if (isActive !== undefined) data.isActive = isActive;
|
||||||
|
|
||||||
|
const district = await db.schoolDistrict.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
schools: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(district);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[districts/[id]] PUT error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to update district' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/districts/[id]
|
||||||
|
export async function DELETE(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Remove district from schools first
|
||||||
|
await db.school.updateMany({
|
||||||
|
where: { districtId: id },
|
||||||
|
data: { districtId: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schoolDistrict.update({
|
||||||
|
where: { id },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[districts/[id]] DELETE error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to delete district' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
// GET /api/districts/[id]/schools — schools in district
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const district = await db.schoolDistrict.findFirst({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!district) {
|
||||||
|
return NextResponse.json({ error: 'District not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const schools = await db.school.findMany({
|
||||||
|
where: { districtId: id, deletedAt: null },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { students: true, classGroups: true, users: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(schools);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[districts/[id]/schools] GET error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch district schools' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/districts/[id]/schools — assign school to district
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await req.json();
|
||||||
|
const { schoolId } = body;
|
||||||
|
|
||||||
|
if (!schoolId) {
|
||||||
|
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.school.update({
|
||||||
|
where: { id: schoolId },
|
||||||
|
data: { districtId: id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[districts/[id]/schools] POST error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to assign school to district' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
// GET /api/districts
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const districts = await db.schoolDistrict.findMany({
|
||||||
|
where: { deletedAt: null },
|
||||||
|
include: {
|
||||||
|
schools: {
|
||||||
|
where: { deletedAt: null },
|
||||||
|
select: { id: true, name: true, schoolType: true, country: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add computed stats
|
||||||
|
const result = districts.map((d) => ({
|
||||||
|
...d,
|
||||||
|
schoolCount: d.schools.length,
|
||||||
|
totalStudents: 0, // Would need aggregation query in production
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[districts] GET error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch districts' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/districts
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { name, code, region, country, adminEmail } = body;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return NextResponse.json({ error: 'name is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const district = await db.schoolDistrict.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
code: code || null,
|
||||||
|
region: region || null,
|
||||||
|
country: country || 'DE',
|
||||||
|
adminEmail: adminEmail || null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
schools: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(district, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[districts] POST error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to create district' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getRateLimitStats } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
// GET /api/rate-limit-stats — Get rate limit statistics (admin monitoring)
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const stats = getRateLimitStats();
|
||||||
|
return NextResponse.json(stats);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Rate limit stats error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
// GET /api/report-schedules/[id]
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const schedule = await db.reportSchedule.findFirst({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
include: {
|
||||||
|
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||||
|
school: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!schedule) {
|
||||||
|
return NextResponse.json({ error: 'Schedule not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(schedule);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[report-schedules/[id]] GET error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch schedule' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/report-schedules/[id]
|
||||||
|
export async function PUT(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await req.json();
|
||||||
|
const {
|
||||||
|
classGroupId,
|
||||||
|
template,
|
||||||
|
frequency,
|
||||||
|
dayOfWeek,
|
||||||
|
dayOfMonth,
|
||||||
|
monthOfYear,
|
||||||
|
recipients,
|
||||||
|
includeStudents,
|
||||||
|
includeGrades,
|
||||||
|
includeAttendance,
|
||||||
|
includeBehavior,
|
||||||
|
includeCompetencies,
|
||||||
|
isActive,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if (classGroupId !== undefined) data.classGroupId = classGroupId || null;
|
||||||
|
if (template !== undefined) data.template = template;
|
||||||
|
if (frequency !== undefined) data.frequency = frequency;
|
||||||
|
if (dayOfWeek !== undefined) data.dayOfWeek = dayOfWeek;
|
||||||
|
if (dayOfMonth !== undefined) data.dayOfMonth = dayOfMonth;
|
||||||
|
if (monthOfYear !== undefined) data.monthOfYear = monthOfYear;
|
||||||
|
if (recipients !== undefined) data.recipients = recipients ? JSON.stringify(recipients) : null;
|
||||||
|
if (includeStudents !== undefined) data.includeStudents = includeStudents;
|
||||||
|
if (includeGrades !== undefined) data.includeGrades = includeGrades;
|
||||||
|
if (includeAttendance !== undefined) data.includeAttendance = includeAttendance;
|
||||||
|
if (includeBehavior !== undefined) data.includeBehavior = includeBehavior;
|
||||||
|
if (includeCompetencies !== undefined) data.includeCompetencies = includeCompetencies;
|
||||||
|
if (isActive !== undefined) data.isActive = isActive;
|
||||||
|
|
||||||
|
// Recalculate nextRunAt if frequency or timing changed
|
||||||
|
if (frequency || dayOfWeek !== undefined || dayOfMonth !== undefined || monthOfYear !== undefined) {
|
||||||
|
const current = await db.reportSchedule.findUnique({ where: { id } });
|
||||||
|
if (current) {
|
||||||
|
const freq = frequency || current.frequency;
|
||||||
|
const dow = dayOfWeek !== undefined ? dayOfWeek : current.dayOfWeek;
|
||||||
|
const dom = dayOfMonth !== undefined ? dayOfMonth : current.dayOfMonth;
|
||||||
|
const moy = monthOfYear !== undefined ? monthOfYear : current.monthOfYear;
|
||||||
|
data.nextRunAt = calculateNextRun(freq, dow, dom, moy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = await db.reportSchedule.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(schedule);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[report-schedules/[id]] PUT error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to update schedule' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/report-schedules/[id]
|
||||||
|
export async function DELETE(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
await db.reportSchedule.update({
|
||||||
|
where: { id },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[report-schedules/[id]] DELETE error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to delete schedule' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateNextRun(
|
||||||
|
frequency: string,
|
||||||
|
dayOfWeek?: number | null,
|
||||||
|
dayOfMonth?: number | null,
|
||||||
|
monthOfYear?: number | null,
|
||||||
|
): Date {
|
||||||
|
const now = new Date();
|
||||||
|
const next = new Date(now);
|
||||||
|
|
||||||
|
switch (frequency) {
|
||||||
|
case 'weekly': {
|
||||||
|
const targetDay = dayOfWeek ?? 1;
|
||||||
|
const currentDay = next.getDay();
|
||||||
|
const daysUntil = ((targetDay - currentDay + 7) % 7) || 7;
|
||||||
|
next.setDate(next.getDate() + daysUntil);
|
||||||
|
next.setHours(8, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'monthly': {
|
||||||
|
const targetDate = dayOfMonth ?? 1;
|
||||||
|
next.setDate(targetDate);
|
||||||
|
next.setHours(8, 0, 0, 0);
|
||||||
|
if (next <= now) next.setMonth(next.getMonth() + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'quarterly': {
|
||||||
|
const targetMonth = monthOfYear ?? 1;
|
||||||
|
next.setMonth(targetMonth - 1);
|
||||||
|
next.setDate(1);
|
||||||
|
next.setHours(8, 0, 0, 0);
|
||||||
|
if (next <= now) next.setMonth(next.getMonth() + 3);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'end_of_term': {
|
||||||
|
next.setMonth(6);
|
||||||
|
next.setDate(15);
|
||||||
|
next.setHours(8, 0, 0, 0);
|
||||||
|
if (next <= now) next.setFullYear(next.getFullYear() + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
next.setDate(next.getDate() + 7);
|
||||||
|
next.setHours(8, 0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
// POST /api/report-schedules/[id]/run — trigger manual run
|
||||||
|
export async function POST(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const schedule = await db.reportSchedule.findFirst({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
include: {
|
||||||
|
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||||
|
school: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
return NextResponse.json({ error: 'Schedule not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as run and calculate next run
|
||||||
|
const now = new Date();
|
||||||
|
const nextRunAt = calculateNextRun(
|
||||||
|
schedule.frequency,
|
||||||
|
schedule.dayOfWeek,
|
||||||
|
schedule.dayOfMonth,
|
||||||
|
schedule.monthOfYear,
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.reportSchedule.update({
|
||||||
|
where: { id },
|
||||||
|
data: { lastRunAt: now, nextRunAt },
|
||||||
|
});
|
||||||
|
|
||||||
|
// In a real system, this would trigger the actual report generation pipeline
|
||||||
|
// and send notifications to recipients. For now, we simulate success.
|
||||||
|
const recipientIds: string[] = schedule.recipients
|
||||||
|
? JSON.parse(schedule.recipients)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Create notifications for recipients
|
||||||
|
if (recipientIds.length > 0) {
|
||||||
|
try {
|
||||||
|
await db.notification.createMany({
|
||||||
|
data: recipientIds.map((userId) => ({
|
||||||
|
schoolId: schedule.schoolId,
|
||||||
|
userId,
|
||||||
|
type: 'report_ready',
|
||||||
|
title: `Scheduled Report Generated`,
|
||||||
|
message: `Report schedule "${schedule.template}" for ${schedule.classGroup?.name ?? 'all classes'} has been generated.`,
|
||||||
|
actionUrl: '/reports',
|
||||||
|
relatedId: schedule.id,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Notification creation is non-critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
lastRunAt: now,
|
||||||
|
nextRunAt,
|
||||||
|
recipientsNotified: recipientIds.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[report-schedules/[id]/run] POST error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to run schedule' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateNextRun(
|
||||||
|
frequency: string,
|
||||||
|
dayOfWeek?: number | null,
|
||||||
|
dayOfMonth?: number | null,
|
||||||
|
monthOfYear?: number | null,
|
||||||
|
): Date {
|
||||||
|
const now = new Date();
|
||||||
|
const next = new Date(now);
|
||||||
|
|
||||||
|
switch (frequency) {
|
||||||
|
case 'weekly': {
|
||||||
|
const targetDay = dayOfWeek ?? 1;
|
||||||
|
const currentDay = next.getDay();
|
||||||
|
const daysUntil = ((targetDay - currentDay + 7) % 7) || 7;
|
||||||
|
next.setDate(next.getDate() + daysUntil);
|
||||||
|
next.setHours(8, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'monthly': {
|
||||||
|
const targetDate = dayOfMonth ?? 1;
|
||||||
|
next.setDate(targetDate);
|
||||||
|
next.setHours(8, 0, 0, 0);
|
||||||
|
if (next <= now) next.setMonth(next.getMonth() + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'quarterly': {
|
||||||
|
const targetMonth = monthOfYear ?? 1;
|
||||||
|
next.setMonth(targetMonth - 1);
|
||||||
|
next.setDate(1);
|
||||||
|
next.setHours(8, 0, 0, 0);
|
||||||
|
if (next <= now) next.setMonth(next.getMonth() + 3);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'end_of_term': {
|
||||||
|
next.setMonth(6);
|
||||||
|
next.setDate(15);
|
||||||
|
next.setHours(8, 0, 0, 0);
|
||||||
|
if (next <= now) next.setFullYear(next.getFullYear() + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
next.setDate(next.getDate() + 7);
|
||||||
|
next.setHours(8, 0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
// GET /api/report-schedules?schoolId=xxx
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const schoolId = req.nextUrl.searchParams.get('schoolId');
|
||||||
|
if (!schoolId) {
|
||||||
|
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedules = await db.reportSchedule.findMany({
|
||||||
|
where: { schoolId, deletedAt: null },
|
||||||
|
include: {
|
||||||
|
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(schedules);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[report-schedules] GET error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch report schedules' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/report-schedules
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const {
|
||||||
|
schoolId,
|
||||||
|
classGroupId,
|
||||||
|
template,
|
||||||
|
frequency,
|
||||||
|
dayOfWeek,
|
||||||
|
dayOfMonth,
|
||||||
|
monthOfYear,
|
||||||
|
recipients,
|
||||||
|
includeStudents,
|
||||||
|
includeGrades,
|
||||||
|
includeAttendance,
|
||||||
|
includeBehavior,
|
||||||
|
includeCompetencies,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!schoolId || !template || !frequency) {
|
||||||
|
return NextResponse.json({ error: 'schoolId, template, and frequency are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate nextRunAt
|
||||||
|
const nextRunAt = calculateNextRun(frequency, dayOfWeek, dayOfMonth, monthOfYear);
|
||||||
|
|
||||||
|
const schedule = await db.reportSchedule.create({
|
||||||
|
data: {
|
||||||
|
schoolId,
|
||||||
|
classGroupId: classGroupId || null,
|
||||||
|
template,
|
||||||
|
frequency,
|
||||||
|
dayOfWeek: dayOfWeek ?? null,
|
||||||
|
dayOfMonth: dayOfMonth ?? null,
|
||||||
|
monthOfYear: monthOfYear ?? null,
|
||||||
|
recipients: recipients ? JSON.stringify(recipients) : null,
|
||||||
|
includeStudents: includeStudents ?? true,
|
||||||
|
includeGrades: includeGrades ?? true,
|
||||||
|
includeAttendance: includeAttendance ?? true,
|
||||||
|
includeBehavior: includeBehavior ?? false,
|
||||||
|
includeCompetencies: includeCompetencies ?? true,
|
||||||
|
nextRunAt,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(schedule, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[report-schedules] POST error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to create report schedule' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateNextRun(
|
||||||
|
frequency: string,
|
||||||
|
dayOfWeek?: number | null,
|
||||||
|
dayOfMonth?: number | null,
|
||||||
|
monthOfYear?: number | null,
|
||||||
|
): Date {
|
||||||
|
const now = new Date();
|
||||||
|
const next = new Date(now);
|
||||||
|
|
||||||
|
switch (frequency) {
|
||||||
|
case 'weekly': {
|
||||||
|
const targetDay = dayOfWeek ?? 1; // Monday default
|
||||||
|
const currentDay = next.getDay();
|
||||||
|
const daysUntil = ((targetDay - currentDay + 7) % 7) || 7;
|
||||||
|
next.setDate(next.getDate() + daysUntil);
|
||||||
|
next.setHours(8, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'monthly': {
|
||||||
|
const targetDate = dayOfMonth ?? 1;
|
||||||
|
next.setDate(targetDate);
|
||||||
|
next.setHours(8, 0, 0, 0);
|
||||||
|
if (next <= now) {
|
||||||
|
next.setMonth(next.getMonth() + 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'quarterly': {
|
||||||
|
const targetMonth = monthOfYear ?? 1;
|
||||||
|
next.setMonth(targetMonth - 1);
|
||||||
|
next.setDate(1);
|
||||||
|
next.setHours(8, 0, 0, 0);
|
||||||
|
if (next <= now) {
|
||||||
|
next.setMonth(next.getMonth() + 3);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'end_of_term': {
|
||||||
|
// Default: July 15 (end of German school year)
|
||||||
|
next.setMonth(6); // July
|
||||||
|
next.setDate(15);
|
||||||
|
next.setHours(8, 0, 0, 0);
|
||||||
|
if (next <= now) {
|
||||||
|
next.setFullYear(next.getFullYear() + 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
next.setDate(next.getDate() + 7);
|
||||||
|
next.setHours(8, 0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { getSession } from '@/lib/auth';
|
import { getSession } from '@/lib/auth';
|
||||||
|
import { withRateLimit } from '@/lib/rate-limit';
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export const GET = withRateLimit(async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -105,7 +106,7 @@ export async function GET(request: Request) {
|
|||||||
console.error('Report PDF error:', error);
|
console.error('Report PDF error:', error);
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}, 'heavy');
|
||||||
|
|
||||||
function generatePrintHtml(report: Record<string, unknown>, template: string): string {
|
function generatePrintHtml(report: Record<string, unknown>, template: string): string {
|
||||||
const student = report.student as { firstName: string; lastName: string };
|
const student = report.student as { firstName: string; lastName: string };
|
||||||
|
|||||||
@@ -1548,3 +1548,258 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Report Scheduling Styles (Task 13) ────────────────────────────────── */
|
||||||
|
@layer utilities {
|
||||||
|
/* Schedule timeline visualization */
|
||||||
|
.schedule-timeline {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
.schedule-timeline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0.5rem;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: linear-gradient(to bottom, oklch(0.55 0.17 155), oklch(0.65 0.12 175));
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
.schedule-timeline-item {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.schedule-timeline-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -1.1rem;
|
||||||
|
top: 0.35rem;
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: oklch(0.55 0.17 155);
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 0 0 2px oklch(0.55 0.17 155 / 0.3);
|
||||||
|
}
|
||||||
|
.schedule-timeline-item.inactive::before {
|
||||||
|
background: oklch(0.7 0 0);
|
||||||
|
box-shadow: 0 0 0 2px oklch(0.7 0 0 / 0.3);
|
||||||
|
}
|
||||||
|
.schedule-timeline-item:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* District card styling */
|
||||||
|
.district-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 1rem;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.district-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px -5px oklch(0 0 0 / 0.1), 0 0 0 1px oklch(0.55 0.17 155 / 0.1);
|
||||||
|
}
|
||||||
|
.district-card::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(to right, oklch(0.55 0.17 155), oklch(0.65 0.12 175), oklch(0.55 0.17 155));
|
||||||
|
}
|
||||||
|
.dark .district-card::after {
|
||||||
|
background: linear-gradient(to right, oklch(0.65 0.17 155), oklch(0.75 0.12 175), oklch(0.65 0.17 155));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cross-school comparison styling */
|
||||||
|
.cross-school-comparison {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.cross-school-comparison .comparison-bar {
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: oklch(0.95 0.02 155);
|
||||||
|
}
|
||||||
|
.dark .cross-school-comparison .comparison-bar {
|
||||||
|
background: oklch(0.25 0.02 155);
|
||||||
|
}
|
||||||
|
.cross-school-comparison .comparison-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Report generation progress bar */
|
||||||
|
.report-progress {
|
||||||
|
position: relative;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: oklch(0.93 0.02 155);
|
||||||
|
}
|
||||||
|
.dark .report-progress {
|
||||||
|
background: oklch(0.25 0.02 155);
|
||||||
|
}
|
||||||
|
.report-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: linear-gradient(to right, oklch(0.55 0.17 155), oklch(0.65 0.12 175));
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.report-progress-fill::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, oklch(1 0 0 / 0.3), transparent);
|
||||||
|
animation: shimmer-progress 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes shimmer-progress {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section divider with gradient line */
|
||||||
|
.section-divider {
|
||||||
|
position: relative;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(to right, transparent, oklch(0.55 0.17 155 / 0.3), transparent);
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
.dark .section-divider {
|
||||||
|
background: linear-gradient(to right, transparent, oklch(0.65 0.17 155 / 0.3), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Offline / PWA Styles ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Offline indicator bar */
|
||||||
|
.offline-indicator-bar {
|
||||||
|
z-index: 100;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PWA install prompt */
|
||||||
|
.pwa-install-prompt {
|
||||||
|
animation: slide-up-fade 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Offline badge pulse */
|
||||||
|
.offline-badge-pulse {
|
||||||
|
animation: offline-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes offline-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Offline sync spinner */
|
||||||
|
.offline-sync-spinner {
|
||||||
|
animation: offline-sync-spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes offline-sync-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cached notebook indicator */
|
||||||
|
.cached-notebook-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rate limit status card */
|
||||||
|
.rate-limit-card {
|
||||||
|
border: 1px solid oklch(0.55 0.17 155 / 0.2);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: oklch(0.98 0.01 155 / 0.5);
|
||||||
|
}
|
||||||
|
.dark .rate-limit-card {
|
||||||
|
border-color: oklch(0.55 0.17 155 / 0.15);
|
||||||
|
background: oklch(0.2 0.02 155 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rate limit bar */
|
||||||
|
.rate-limit-bar {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: oklch(0.92 0.01 155);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.dark .rate-limit-bar {
|
||||||
|
background: oklch(0.25 0.01 155);
|
||||||
|
}
|
||||||
|
.rate-limit-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PWA standalone mode adjustments */
|
||||||
|
@media (display-mode: standalone) {
|
||||||
|
body {
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
padding-left: env(safe-area-inset-left);
|
||||||
|
padding-right: env(safe-area-inset-right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Offline mode visual feedback */
|
||||||
|
.offline-mode-active {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-mode-active .offline-dimmed {
|
||||||
|
opacity: 0.7;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast rate limit countdown */
|
||||||
|
.rate-limit-countdown {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
color: oklch(0.55 0.17 25);
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-2
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
@@ -19,9 +19,23 @@ export const metadata: Metadata = {
|
|||||||
description: "Open-Source School Software for Competency-Based Assessment and Grading. Built with Next.js, TypeScript, and shadcn/ui.",
|
description: "Open-Source School Software for Competency-Based Assessment and Grading. Built with Next.js, TypeScript, and shadcn/ui.",
|
||||||
keywords: ["CompetenceTrack", "competency", "grading", "school", "education", "Next.js", "TypeScript", "shadcn/ui"],
|
keywords: ["CompetenceTrack", "competency", "grading", "school", "education", "Next.js", "TypeScript", "shadcn/ui"],
|
||||||
authors: [{ name: "CompetenceTrack Team" }],
|
authors: [{ name: "CompetenceTrack Team" }],
|
||||||
|
manifest: "/manifest.json",
|
||||||
icons: {
|
icons: {
|
||||||
icon: "https://z-cdn.chatglm.cn/z-ai/static/logo.svg",
|
icon: "https://z-cdn.chatglm.cn/z-ai/static/logo.svg",
|
||||||
|
apple: "/icon-192.png",
|
||||||
},
|
},
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "default",
|
||||||
|
title: "CompetenceTrack",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#10b981",
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -30,7 +44,10 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="de" suppressHydrationWarning>
|
<html lang="de" suppressHydrationWarning className="min-h-screen">
|
||||||
|
<head>
|
||||||
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
|
</head>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ import {
|
|||||||
} from '@/components/ui/command';
|
} from '@/components/ui/command';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useWebSocket, usePushNotifications, playNotificationSound, getNotificationSoundPref } from '@/lib/websocket';
|
import { useWebSocket, usePushNotifications, playNotificationSound, getNotificationSoundPref } from '@/lib/websocket';
|
||||||
|
import { OfflineIndicator, PWAInstallPrompt, useServiceWorker, OfflineSyncManager } from '@/components/offline-indicator';
|
||||||
|
|
||||||
import DashboardView from './dashboard-view';
|
import DashboardView from './dashboard-view';
|
||||||
import ClassesView from './classes-view';
|
import ClassesView from './classes-view';
|
||||||
@@ -257,6 +258,9 @@ export default function AppLayout() {
|
|||||||
const [schoolYears, setSchoolYears] = useState<SchoolYear[]>([]);
|
const [schoolYears, setSchoolYears] = useState<SchoolYear[]>([]);
|
||||||
const [selectedYearId, setSelectedYearId] = useState<string>(storeSchoolYearId ?? '');
|
const [selectedYearId, setSelectedYearId] = useState<string>(storeSchoolYearId ?? '');
|
||||||
|
|
||||||
|
// Service Worker registration for PWA
|
||||||
|
useServiceWorker();
|
||||||
|
|
||||||
// WebSocket connection for real-time push notifications
|
// WebSocket connection for real-time push notifications
|
||||||
const { connected: wsConnected } = useWebSocket();
|
const { connected: wsConnected } = useWebSocket();
|
||||||
const { notification: pushNotification } = usePushNotifications();
|
const { notification: pushNotification } = usePushNotifications();
|
||||||
@@ -636,6 +640,12 @@ export default function AppLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
|
{/* Offline indicator bar */}
|
||||||
|
<OfflineIndicator />
|
||||||
|
{/* PWA install prompt */}
|
||||||
|
<PWAInstallPrompt />
|
||||||
|
{/* Offline sync manager */}
|
||||||
|
<OfflineSyncManager />
|
||||||
<Sidebar
|
<Sidebar
|
||||||
variant="sidebar"
|
variant="sidebar"
|
||||||
collapsible="icon"
|
collapsible="icon"
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,507 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { WifiOff, Wifi, Download, CloudOff, RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
|
// ─── Offline Indicator Bar ────────────────────────────────────────────────
|
||||||
|
export function OfflineIndicator() {
|
||||||
|
const [isOffline, setIsOffline] = useState(false);
|
||||||
|
const [wasOffline, setWasOffline] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Set initial state
|
||||||
|
setIsOffline(!navigator.onLine);
|
||||||
|
|
||||||
|
const handleOnline = () => {
|
||||||
|
setIsOffline(false);
|
||||||
|
if (wasOffline) {
|
||||||
|
toast.success(t('pwa.back_online'), {
|
||||||
|
description: t('pwa.back_online_desc'),
|
||||||
|
duration: 4000,
|
||||||
|
icon: <Wifi className="h-4 w-4 text-emerald-500" />,
|
||||||
|
});
|
||||||
|
// Trigger sync of pending requests
|
||||||
|
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||||
|
navigator.serviceWorker.controller.postMessage({ type: 'SYNC_PENDING' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOffline = () => {
|
||||||
|
setIsOffline(true);
|
||||||
|
setWasOffline(true);
|
||||||
|
toast.error(t('pwa.offline'), {
|
||||||
|
description: t('pwa.offline_desc'),
|
||||||
|
duration: 6000,
|
||||||
|
icon: <WifiOff className="h-4 w-4" />,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
};
|
||||||
|
}, [wasOffline]);
|
||||||
|
|
||||||
|
// Reset wasOffline after a short delay when back online
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOffline && wasOffline) {
|
||||||
|
const timer = setTimeout(() => setWasOffline(false), 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isOffline, wasOffline]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOffline && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: -40, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: -40, opacity: 0 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||||
|
className="offline-indicator-bar fixed top-0 left-0 right-0 z-[100] flex items-center justify-center gap-2 px-4 py-2 bg-amber-500 text-white text-sm font-medium shadow-lg shadow-amber-500/20"
|
||||||
|
>
|
||||||
|
<WifiOff className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{t('pwa.offline')}</span>
|
||||||
|
<span className="hidden sm:inline text-amber-100">— {t('pwa.offline_desc')}</span>
|
||||||
|
<Badge variant="outline" className="ml-2 text-[10px] border-amber-200/50 text-amber-100 bg-amber-600/40">
|
||||||
|
<CloudOff className="h-3 w-3 mr-1" />
|
||||||
|
{t('pwa.cached_data')}
|
||||||
|
</Badge>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PWA Install Prompt ───────────────────────────────────────────────────
|
||||||
|
export function PWAInstallPrompt() {
|
||||||
|
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||||
|
const [showInstall, setShowInstall] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if the app is already installed
|
||||||
|
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBeforeInstall = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||||
|
setShowInstall(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAppInstalled = () => {
|
||||||
|
setShowInstall(false);
|
||||||
|
setDeferredPrompt(null);
|
||||||
|
toast.success(t('pwa.installed'), {
|
||||||
|
description: t('pwa.installed_desc'),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', handleBeforeInstall);
|
||||||
|
window.addEventListener('appinstalled', handleAppInstalled);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
|
||||||
|
window.removeEventListener('appinstalled', handleAppInstalled);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInstall = useCallback(async () => {
|
||||||
|
if (!deferredPrompt) return;
|
||||||
|
try {
|
||||||
|
await (deferredPrompt as BeforeInstallPromptEvent).prompt();
|
||||||
|
const result = await (deferredPrompt as BeforeInstallPromptEvent).userChoice;
|
||||||
|
if (result.outcome === 'accepted') {
|
||||||
|
toast.success(t('pwa.install_accepted'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
setDeferredPrompt(null);
|
||||||
|
setShowInstall(false);
|
||||||
|
}, [deferredPrompt]);
|
||||||
|
|
||||||
|
if (!showInstall) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 10 }}
|
||||||
|
className="fixed bottom-4 right-4 z-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3 bg-white dark:bg-gray-900 rounded-xl border border-emerald-200/60 dark:border-emerald-900/40 shadow-xl shadow-emerald-900/10">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shrink-0">
|
||||||
|
<Download className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{t('pwa.install_app')}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{t('pwa.install_app_desc')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowInstall(false)}
|
||||||
|
className="h-8 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
{t('action.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleInstall}
|
||||||
|
className="h-8 text-xs bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-sm"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5 mr-1" />
|
||||||
|
{t('pwa.install')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Service Worker Registration Hook ──────────────────────────────────────
|
||||||
|
export function useServiceWorker() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!('serviceWorker' in navigator)) return;
|
||||||
|
|
||||||
|
async function registerSW() {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.register('/sw.js', {
|
||||||
|
scope: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for updates every 30 minutes
|
||||||
|
setInterval(() => {
|
||||||
|
registration.update();
|
||||||
|
}, 30 * 60 * 1000);
|
||||||
|
|
||||||
|
// Handle updates
|
||||||
|
registration.addEventListener('updatefound', () => {
|
||||||
|
const newWorker = registration.installing;
|
||||||
|
if (newWorker) {
|
||||||
|
newWorker.addEventListener('statechange', () => {
|
||||||
|
if (newWorker.state === 'activated') {
|
||||||
|
toast.info(t('pwa.update_available'), {
|
||||||
|
description: t('pwa.update_available_desc'),
|
||||||
|
action: {
|
||||||
|
label: t('pwa.reload'),
|
||||||
|
onClick: () => window.location.reload(),
|
||||||
|
},
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[PWA] Service Worker registered successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[PWA] Service Worker registration failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerSW();
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Offline Badge for Notebooks ──────────────────────────────────────────
|
||||||
|
export function OfflineBadge() {
|
||||||
|
const [isOffline, setIsOffline] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsOffline(!navigator.onLine);
|
||||||
|
const handleOnline = () => setIsOffline(false);
|
||||||
|
const handleOffline = () => setIsOffline(true);
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isOffline) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-[10px] border-amber-300/60 dark:border-amber-700/40 text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 gap-1">
|
||||||
|
<CloudOff className="h-3 w-3" />
|
||||||
|
{t('pwa.offline_mode')}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── BeforeInstallPromptEvent type ────────────────────────────────────────
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt(): Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Offline Form Submission Queue ────────────────────────────────────────
|
||||||
|
const OFFLINE_QUEUE_KEY = 'ct_offline_queue';
|
||||||
|
|
||||||
|
interface QueuedRequest {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
method: string;
|
||||||
|
body: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
timestamp: number;
|
||||||
|
retries: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueOfflineRequest(
|
||||||
|
url: string,
|
||||||
|
method: string,
|
||||||
|
body: string,
|
||||||
|
headers: Record<string, string> = {}
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
const queue: QueuedRequest[] = JSON.parse(localStorage.getItem(OFFLINE_QUEUE_KEY) || '[]');
|
||||||
|
queue.push({
|
||||||
|
id: `req_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
retries: 0,
|
||||||
|
});
|
||||||
|
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue));
|
||||||
|
} catch {
|
||||||
|
// localStorage not available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOfflineQueue(): QueuedRequest[] {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(OFFLINE_QUEUE_KEY) || '[]');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearOfflineQueue(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(OFFLINE_QUEUE_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeOfflineRequest(id: string): void {
|
||||||
|
try {
|
||||||
|
const queue: QueuedRequest[] = JSON.parse(localStorage.getItem(OFFLINE_QUEUE_KEY) || '[]');
|
||||||
|
const filtered = queue.filter((r) => r.id !== id);
|
||||||
|
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(filtered));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Offline Sync Manager ─────────────────────────────────────────────────
|
||||||
|
export function OfflineSyncManager() {
|
||||||
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateCount = () => {
|
||||||
|
setPendingCount(getOfflineQueue().length);
|
||||||
|
};
|
||||||
|
updateCount();
|
||||||
|
|
||||||
|
// Check every 10 seconds
|
||||||
|
const interval = setInterval(updateCount, 10000);
|
||||||
|
|
||||||
|
// When back online, try to replay queued requests
|
||||||
|
const handleOnline = async () => {
|
||||||
|
const queue = getOfflineQueue();
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
for (const req of queue) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(req.url, {
|
||||||
|
method: req.method,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...req.headers },
|
||||||
|
body: req.method !== 'GET' ? req.body : undefined,
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
removeOfflineRequest(req.id);
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
toast.success(t('pwa.sync_complete'), {
|
||||||
|
description: t('pwa.sync_complete_desc').replace('{count}', String(successCount)),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateCount();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (pendingCount === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 left-4 z-50">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200/60 dark:border-amber-800/40 rounded-lg shadow-md"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 text-amber-500 animate-spin" />
|
||||||
|
<span className="text-xs text-amber-700 dark:text-amber-300 font-medium">
|
||||||
|
{t('pwa.pending_sync').replace('{count}', String(pendingCount))}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Rate Limit Toast Handler ───────────────────────────────────────
|
||||||
|
// This component provides a utility to show rate limit error toasts
|
||||||
|
// Call showRateLimitToast from any catch block when a 429 is received
|
||||||
|
|
||||||
|
export function showRateLimitToast(retryAfterSeconds: number, limit?: number): void {
|
||||||
|
const retryAfterFormatted = retryAfterSeconds > 60
|
||||||
|
? `${Math.ceil(retryAfterSeconds / 60)} min`
|
||||||
|
: `${retryAfterSeconds}s`;
|
||||||
|
|
||||||
|
toast.error(t('rate_limit.title'), {
|
||||||
|
description: t('rate_limit.description')
|
||||||
|
.replace('{retryAfter}', retryAfterFormatted)
|
||||||
|
.replace('{limit}', limit ? String(limit) : ''),
|
||||||
|
duration: retryAfterSeconds * 1000 + 2000,
|
||||||
|
icon: <Clock className="h-4 w-4" />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Rate Limit Status Card (for admin settings) ────────────────────
|
||||||
|
export function RateLimitStatus() {
|
||||||
|
const [stats, setStats] = useState<{
|
||||||
|
totalEntries: number;
|
||||||
|
topConsumers: Array<{ key: string; count: number; resetTime: number }>;
|
||||||
|
} | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const loadStats = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/rate-limit-stats');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setStats(data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStats();
|
||||||
|
const interval = setInterval(loadStats, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [loadStats]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-semibold text-emerald-700 dark:text-emerald-300 flex items-center gap-1.5">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
{t('rate_limit.status_title')}
|
||||||
|
</h4>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadStats}
|
||||||
|
className="h-8 text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
{t('action.refresh')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-emerald-50/50 dark:bg-emerald-900/10 border border-emerald-200/30 dark:border-emerald-900/20">
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400">{t('rate_limit.active_entries')}</span>
|
||||||
|
<Badge variant="outline" className="text-xs border-emerald-200/50 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300">
|
||||||
|
{stats.totalEntries}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats.topConsumers.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider font-semibold text-emerald-600/70 dark:text-emerald-400/60">
|
||||||
|
{t('rate_limit.top_consumers')}
|
||||||
|
</p>
|
||||||
|
<ScrollArea className="max-h-48">
|
||||||
|
{stats.topConsumers.map((consumer) => (
|
||||||
|
<div key={consumer.key} className="flex items-center justify-between px-2 py-1.5 rounded-lg hover:bg-emerald-50/30 dark:hover:bg-emerald-900/10">
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400 truncate max-w-[200px]">
|
||||||
|
{consumer.key}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Badge variant="outline" className="text-[10px] border-emerald-200/50 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300">
|
||||||
|
{consumer.count}/{t('rate_limit.requests')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stats.topConsumers.length === 0 && (
|
||||||
|
<div className="px-3 py-4 text-center">
|
||||||
|
<Shield className="h-6 w-6 mx-auto mb-1 text-gray-300 dark:text-gray-600" />
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{t('rate_limit.no_active')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!stats && !loading && (
|
||||||
|
<div className="px-3 py-4 text-center">
|
||||||
|
<Shield className="h-6 w-6 mx-auto mb-1 text-gray-300 dark:text-gray-600" />
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{t('rate_limit.unavailable')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Additional icons import ────────────────────────────────────────
|
||||||
|
import { Clock, Shield } from 'lucide-react';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
+1078
-475
File diff suppressed because it is too large.
Load diff
@@ -46,6 +46,11 @@ import {
|
|||||||
Volume2,
|
Volume2,
|
||||||
VolumeX,
|
VolumeX,
|
||||||
Bell,
|
Bell,
|
||||||
|
MapPin,
|
||||||
|
Building2,
|
||||||
|
ArrowRightLeft,
|
||||||
|
TrendingDown,
|
||||||
|
ArrowUpRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -113,7 +118,16 @@ import {
|
|||||||
type Subject,
|
type Subject,
|
||||||
type AuditLogEntry,
|
type AuditLogEntry,
|
||||||
type UserAccount,
|
type UserAccount,
|
||||||
|
exportAuditLogCsv,
|
||||||
|
type SchoolDistrictData,
|
||||||
|
fetchDistricts,
|
||||||
|
createDistrict,
|
||||||
|
updateDistrict,
|
||||||
|
deleteDistrict,
|
||||||
|
fetchDistrictSchools,
|
||||||
|
assignSchoolToDistrict,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
|
import { RateLimitStatus } from '@/components/offline-indicator';
|
||||||
|
|
||||||
const containerVariants = {
|
const containerVariants = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
@@ -204,9 +218,13 @@ export default function SettingsView() {
|
|||||||
const [auditEntries, setAuditEntries] = useState<AuditLogEntry[]>([]);
|
const [auditEntries, setAuditEntries] = useState<AuditLogEntry[]>([]);
|
||||||
const [auditLoading, setAuditLoading] = useState(true);
|
const [auditLoading, setAuditLoading] = useState(true);
|
||||||
const [auditActionFilter, setAuditActionFilter] = useState('all');
|
const [auditActionFilter, setAuditActionFilter] = useState('all');
|
||||||
|
const [auditEntityTypeFilter, setAuditEntityTypeFilter] = useState('all');
|
||||||
const [auditDateFrom, setAuditDateFrom] = useState('');
|
const [auditDateFrom, setAuditDateFrom] = useState('');
|
||||||
const [auditDateTo, setAuditDateTo] = useState('');
|
const [auditDateTo, setAuditDateTo] = useState('');
|
||||||
const [auditSearch, setAuditSearch] = useState('');
|
const [auditSearch, setAuditSearch] = useState('');
|
||||||
|
const [auditPage, setAuditPage] = useState(1);
|
||||||
|
const [auditTotalPages, setAuditTotalPages] = useState(1);
|
||||||
|
const [auditDetailEntry, setAuditDetailEntry] = useState<AuditLogEntry | null>(null);
|
||||||
|
|
||||||
// Data erasure state
|
// Data erasure state
|
||||||
const [showErasureDialog, setShowErasureDialog] = useState(false);
|
const [showErasureDialog, setShowErasureDialog] = useState(false);
|
||||||
@@ -294,6 +312,23 @@ export default function SettingsView() {
|
|||||||
const [autoBackupEnabled, setAutoBackupEnabled] = useState(false);
|
const [autoBackupEnabled, setAutoBackupEnabled] = useState(false);
|
||||||
const [autoBackupFrequency, setAutoBackupFrequency] = useState<'daily' | 'weekly'>('weekly');
|
const [autoBackupFrequency, setAutoBackupFrequency] = useState<'daily' | 'weekly'>('weekly');
|
||||||
|
|
||||||
|
// District state
|
||||||
|
const [districts, setDistricts] = useState<SchoolDistrictData[]>([]);
|
||||||
|
const [districtsLoading, setDistrictsLoading] = useState(false);
|
||||||
|
const [districtDialogOpen, setDistrictDialogOpen] = useState(false);
|
||||||
|
const [editingDistrict, setEditingDistrict] = useState<SchoolDistrictData | null>(null);
|
||||||
|
const [districtForm, setDistrictForm] = useState({ name: '', code: '', region: '', country: 'DE', adminEmail: '' });
|
||||||
|
const [districtSaving, setDistrictSaving] = useState(false);
|
||||||
|
const [deleteDistrictId, setDeleteDistrictId] = useState<string | null>(null);
|
||||||
|
const [districtSchools, setDistrictSchools] = useState<Array<{
|
||||||
|
id: string; name: string; schoolType: string; country: string;
|
||||||
|
_count: { students: number; classGroups: number; users: number };
|
||||||
|
}>>([]);
|
||||||
|
const [districtSchoolsLoading, setDistrictSchoolsLoading] = useState(false);
|
||||||
|
const [selectedDistrictId, setSelectedDistrictId] = useState<string | null>(null);
|
||||||
|
const [assignSchoolId, setAssignSchoolId] = useState('');
|
||||||
|
|
||||||
|
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN';
|
||||||
const isAdmin = currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
const isAdmin = currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||||
|
|
||||||
const loadSchools = useCallback(async () => {
|
const loadSchools = useCallback(async () => {
|
||||||
@@ -359,16 +394,20 @@ export default function SettingsView() {
|
|||||||
const data = await fetchAuditLog({
|
const data = await fetchAuditLog({
|
||||||
schoolId: currentUser?.schoolId ?? undefined,
|
schoolId: currentUser?.schoolId ?? undefined,
|
||||||
action: auditActionFilter !== 'all' ? auditActionFilter : undefined,
|
action: auditActionFilter !== 'all' ? auditActionFilter : undefined,
|
||||||
|
entityType: auditEntityTypeFilter !== 'all' ? auditEntityTypeFilter : undefined,
|
||||||
startDate: auditDateFrom || undefined,
|
startDate: auditDateFrom || undefined,
|
||||||
endDate: auditDateTo || undefined,
|
endDate: auditDateTo || undefined,
|
||||||
|
page: auditPage,
|
||||||
|
pageSize: 50,
|
||||||
});
|
});
|
||||||
setAuditEntries(data);
|
setAuditEntries(data.entries);
|
||||||
|
setAuditTotalPages(data.pagination.totalPages);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('error.generic'));
|
toast.error(t('error.generic'));
|
||||||
} finally {
|
} finally {
|
||||||
setAuditLoading(false);
|
setAuditLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentUser?.schoolId, auditActionFilter, auditDateFrom, auditDateTo]);
|
}, [currentUser?.schoolId, auditActionFilter, auditEntityTypeFilter, auditDateFrom, auditDateTo, auditPage]);
|
||||||
|
|
||||||
const loadUsers = useCallback(async () => {
|
const loadUsers = useCallback(async () => {
|
||||||
setUsersLoading(true);
|
setUsersLoading(true);
|
||||||
@@ -425,7 +464,133 @@ export default function SettingsView() {
|
|||||||
if (tab === 'backup') {
|
if (tab === 'backup') {
|
||||||
loadBackups();
|
loadBackups();
|
||||||
}
|
}
|
||||||
}, [isAdmin, loadDemoAccounts, loadBackups]);
|
if (tab === 'district' && isSuperAdmin) {
|
||||||
|
loadDistricts();
|
||||||
|
}
|
||||||
|
}, [isAdmin, loadDemoAccounts, loadBackups, isSuperAdmin]);
|
||||||
|
|
||||||
|
// District handlers
|
||||||
|
const loadDistricts = useCallback(async () => {
|
||||||
|
setDistrictsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchDistricts();
|
||||||
|
setDistricts(data);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setDistrictsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadDistrictSchoolsData = useCallback(async (districtId: string) => {
|
||||||
|
setSelectedDistrictId(districtId);
|
||||||
|
setDistrictSchoolsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchDistrictSchools(districtId);
|
||||||
|
setDistrictSchools(data);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setDistrictSchoolsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateDistrict = async () => {
|
||||||
|
setDistrictSaving(true);
|
||||||
|
try {
|
||||||
|
await createDistrict({
|
||||||
|
name: districtForm.name,
|
||||||
|
code: districtForm.code || undefined,
|
||||||
|
region: districtForm.region || undefined,
|
||||||
|
country: districtForm.country,
|
||||||
|
adminEmail: districtForm.adminEmail || undefined,
|
||||||
|
});
|
||||||
|
toast.success(t('district.create'));
|
||||||
|
setDistrictDialogOpen(false);
|
||||||
|
resetDistrictForm();
|
||||||
|
loadDistricts();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('error.generic'));
|
||||||
|
} finally {
|
||||||
|
setDistrictSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateDistrict = async () => {
|
||||||
|
if (!editingDistrict) return;
|
||||||
|
setDistrictSaving(true);
|
||||||
|
try {
|
||||||
|
await updateDistrict(editingDistrict.id, {
|
||||||
|
name: districtForm.name,
|
||||||
|
code: districtForm.code || undefined,
|
||||||
|
region: districtForm.region || undefined,
|
||||||
|
country: districtForm.country,
|
||||||
|
adminEmail: districtForm.adminEmail || undefined,
|
||||||
|
});
|
||||||
|
toast.success(t('district.edit'));
|
||||||
|
setDistrictDialogOpen(false);
|
||||||
|
setEditingDistrict(null);
|
||||||
|
resetDistrictForm();
|
||||||
|
loadDistricts();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('error.generic'));
|
||||||
|
} finally {
|
||||||
|
setDistrictSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteDistrict = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deleteDistrict(id);
|
||||||
|
toast.success(t('district.delete'));
|
||||||
|
setDeleteDistrictId(null);
|
||||||
|
if (selectedDistrictId === id) setSelectedDistrictId(null);
|
||||||
|
loadDistricts();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('error.generic'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssignSchool = async () => {
|
||||||
|
if (!selectedDistrictId || !assignSchoolId) return;
|
||||||
|
try {
|
||||||
|
await assignSchoolToDistrict(selectedDistrictId, assignSchoolId);
|
||||||
|
toast.success(t('district.assign_school'));
|
||||||
|
setAssignSchoolId('');
|
||||||
|
loadDistrictSchoolsData(selectedDistrictId);
|
||||||
|
loadDistricts();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('error.generic'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnassignSchool = async (schoolId: string) => {
|
||||||
|
// Unassign by setting districtId to null on the school
|
||||||
|
try {
|
||||||
|
await apiPut(`/api/schools`, { id: schoolId, districtId: null });
|
||||||
|
toast.success(t('district.unassign_school'));
|
||||||
|
if (selectedDistrictId) loadDistrictSchoolsData(selectedDistrictId);
|
||||||
|
loadDistricts();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('error.generic'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDistrict = (district: SchoolDistrictData) => {
|
||||||
|
setEditingDistrict(district);
|
||||||
|
setDistrictForm({
|
||||||
|
name: district.name,
|
||||||
|
code: district.code ?? '',
|
||||||
|
region: district.region ?? '',
|
||||||
|
country: district.country,
|
||||||
|
adminEmail: district.adminEmail ?? '',
|
||||||
|
});
|
||||||
|
setDistrictDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetDistrictForm = () => {
|
||||||
|
setDistrictForm({ name: '', code: '', region: '', country: 'DE', adminEmail: '' });
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateBackup = useCallback(async () => {
|
const handleCreateBackup = useCallback(async () => {
|
||||||
if (!currentUser?.schoolId) return;
|
if (!currentUser?.schoolId) return;
|
||||||
@@ -962,6 +1127,16 @@ export default function SettingsView() {
|
|||||||
<HardDrive className="h-4 w-4 mr-1.5" />
|
<HardDrive className="h-4 w-4 mr-1.5" />
|
||||||
<span className="hidden sm:inline">{t('backup.title')}</span>
|
<span className="hidden sm:inline">{t('backup.title')}</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<TabsTrigger value="district" className="rounded-lg min-h-[44px] data-[state=active]:bg-violet-500 data-[state=active]:text-white">
|
||||||
|
<Building2 className="h-4 w-4 mr-1.5" />
|
||||||
|
<span className="hidden sm:inline">{t('district.title')}</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
<TabsTrigger value="rate-limit" className="rounded-lg min-h-[44px] data-[state=active]:bg-rose-500 data-[state=active]:text-white">
|
||||||
|
<Shield className="h-4 w-4 mr-1.5" />
|
||||||
|
<span className="hidden sm:inline">{t('rate_limit.status_title')}</span>
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* ── School Info Tab ─────────────────────────────────── */}
|
{/* ── School Info Tab ─────────────────────────────────── */}
|
||||||
@@ -1692,12 +1867,29 @@ export default function SettingsView() {
|
|||||||
<TabsContent value="audit">
|
<TabsContent value="audit">
|
||||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
|
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
|
||||||
<CardHeader className="bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
|
<CardHeader className="bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Shield className="h-4 w-4" />
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
|
||||||
</div>
|
<Shield className="h-4 w-4" />
|
||||||
{t('settings.audit_title')}
|
</div>
|
||||||
</CardTitle>
|
{t('settings.audit_title')}
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => exportAuditLogCsv({
|
||||||
|
schoolId: currentUser?.schoolId ?? undefined,
|
||||||
|
action: auditActionFilter !== 'all' ? auditActionFilter : undefined,
|
||||||
|
entityType: auditEntityTypeFilter !== 'all' ? auditEntityTypeFilter : undefined,
|
||||||
|
startDate: auditDateFrom || undefined,
|
||||||
|
endDate: auditDateTo || undefined,
|
||||||
|
})}
|
||||||
|
className="h-8 rounded-lg border-violet-200 dark:border-violet-900/30 text-violet-700 dark:text-violet-300 min-h-[36px]"
|
||||||
|
>
|
||||||
|
<FileDown className="h-3.5 w-3.5 mr-1" />
|
||||||
|
{t('audit.export_csv')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<CardDescription>{t('settings.audit_action')} & {t('settings.audit_timestamp')}</CardDescription>
|
<CardDescription>{t('settings.audit_action')} & {t('settings.audit_timestamp')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -1724,8 +1916,8 @@ export default function SettingsView() {
|
|||||||
].map((chip) => (
|
].map((chip) => (
|
||||||
<button
|
<button
|
||||||
key={chip.value}
|
key={chip.value}
|
||||||
onClick={() => setAuditActionFilter(chip.value)}
|
onClick={() => { setAuditActionFilter(chip.value); setAuditPage(1); }}
|
||||||
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-all ${
|
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-all min-h-[36px] ${
|
||||||
auditActionFilter === chip.value
|
auditActionFilter === chip.value
|
||||||
? `${chip.color} ring-1 shadow-sm`
|
? `${chip.color} ring-1 shadow-sm`
|
||||||
: 'bg-white/60 dark:bg-gray-800/40 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 border border-gray-200/50 dark:border-gray-700/30'
|
: 'bg-white/60 dark:bg-gray-800/40 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 border border-gray-200/50 dark:border-gray-700/30'
|
||||||
@@ -1736,6 +1928,30 @@ export default function SettingsView() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Entity type filter */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<Label className="text-xs text-gray-500 dark:text-gray-400 self-center">{t('audit.entity_type_filter')}</Label>
|
||||||
|
{(() => {
|
||||||
|
const entityTypes = Array.from(new Set(auditEntries.map((e) => e.entityType))).sort();
|
||||||
|
return [
|
||||||
|
{ value: 'all', label: t('audit.all_entity_types') },
|
||||||
|
...entityTypes.map((et) => ({ value: et, label: et })),
|
||||||
|
].map((chip) => (
|
||||||
|
<button
|
||||||
|
key={chip.value}
|
||||||
|
onClick={() => { setAuditEntityTypeFilter(chip.value); setAuditPage(1); }}
|
||||||
|
className={`px-2 py-1 rounded-md text-xs font-medium transition-all min-h-[36px] ${
|
||||||
|
auditEntityTypeFilter === chip.value
|
||||||
|
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 ring-1 ring-violet-300 dark:ring-violet-700'
|
||||||
|
: 'bg-white/60 dark:bg-gray-800/40 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 border border-gray-200/50 dark:border-gray-700/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{chip.label}
|
||||||
|
</button>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Date filters */}
|
{/* Date filters */}
|
||||||
<div className="flex flex-wrap gap-3 items-end">
|
<div className="flex flex-wrap gap-3 items-end">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -1743,7 +1959,7 @@ export default function SettingsView() {
|
|||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
value={auditDateFrom}
|
value={auditDateFrom}
|
||||||
onChange={(e) => setAuditDateFrom(e.target.value)}
|
onChange={(e) => { setAuditDateFrom(e.target.value); setAuditPage(1); }}
|
||||||
className="h-8 w-36 rounded-lg text-xs border-violet-200 dark:border-violet-900/30"
|
className="h-8 w-36 rounded-lg text-xs border-violet-200 dark:border-violet-900/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1752,7 +1968,7 @@ export default function SettingsView() {
|
|||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
value={auditDateTo}
|
value={auditDateTo}
|
||||||
onChange={(e) => setAuditDateTo(e.target.value)}
|
onChange={(e) => { setAuditDateTo(e.target.value); setAuditPage(1); }}
|
||||||
className="h-8 w-36 rounded-lg text-xs border-violet-200 dark:border-violet-900/30"
|
className="h-8 w-36 rounded-lg text-xs border-violet-200 dark:border-violet-900/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1760,7 +1976,7 @@ export default function SettingsView() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={loadAuditLog}
|
onClick={loadAuditLog}
|
||||||
className="h-8 rounded-lg border-violet-200 dark:border-violet-900/30 text-violet-700 dark:text-violet-300"
|
className="h-8 rounded-lg border-violet-200 dark:border-violet-900/30 text-violet-700 dark:text-violet-300 min-h-[36px]"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-3.5 w-3.5 mr-1" />
|
<RefreshCw className="h-3.5 w-3.5 mr-1" />
|
||||||
{t('action.refresh')}
|
{t('action.refresh')}
|
||||||
@@ -1824,7 +2040,8 @@ export default function SettingsView() {
|
|||||||
initial={{ opacity: 0, x: -8 }}
|
initial={{ opacity: 0, x: -8 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ delay: i * 0.03 }}
|
transition={{ delay: i * 0.03 }}
|
||||||
className="relative flex items-start gap-4 pl-4 py-3 group"
|
className="relative flex items-start gap-4 pl-4 py-3 group cursor-pointer"
|
||||||
|
onClick={() => setAuditDetailEntry(entry)}
|
||||||
>
|
>
|
||||||
{/* Timeline dot */}
|
{/* Timeline dot */}
|
||||||
<div className={`absolute left-3.5 w-3 h-3 rounded-full ring-2 ring-white dark:ring-gray-900 shrink-0 ${dotColor} group-hover:scale-125 transition-transform`} style={{ zIndex: 1 }} />
|
<div className={`absolute left-3.5 w-3 h-3 rounded-full ring-2 ring-white dark:ring-gray-900 shrink-0 ${dotColor} group-hover:scale-125 transition-transform`} style={{ zIndex: 1 }} />
|
||||||
@@ -1864,6 +2081,9 @@ export default function SettingsView() {
|
|||||||
</div>
|
</div>
|
||||||
<span>{actorName}</span>
|
<span>{actorName}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{entry.ipAddress && (
|
||||||
|
<span className="text-gray-400 dark:text-gray-500 ml-2">IP: {entry.ipAddress}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -1872,8 +2092,115 @@ export default function SettingsView() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{auditTotalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={auditPage <= 1}
|
||||||
|
onClick={() => setAuditPage((p) => Math.max(1, p - 1))}
|
||||||
|
className="h-8 min-h-[36px] rounded-lg border-violet-200 dark:border-violet-900/30"
|
||||||
|
>
|
||||||
|
{t('audit.previous')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t('audit.page')} {auditPage} {t('audit.of')} {auditTotalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={auditPage >= auditTotalPages}
|
||||||
|
onClick={() => setAuditPage((p) => Math.min(auditTotalPages, p + 1))}
|
||||||
|
className="h-8 min-h-[36px] rounded-lg border-violet-200 dark:border-violet-900/30"
|
||||||
|
>
|
||||||
|
{t('audit.next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Audit Detail Dialog */}
|
||||||
|
<Dialog open={!!auditDetailEntry} onOpenChange={(o) => { if (!o) setAuditDetailEntry(null); }}>
|
||||||
|
<DialogContent className="sm:max-w-[560px] max-h-[90vh] overflow-y-auto">
|
||||||
|
{auditDetailEntry && (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5 text-violet-500" />
|
||||||
|
{t('audit.detail')}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{auditDetailEntry.action} - {auditDetailEntry.entityType}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 py-1">
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">{t('settings.audit_action')}</Label>
|
||||||
|
<p className="mt-1 font-medium">{auditDetailEntry.action}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">{t('audit.entity_type')}</Label>
|
||||||
|
<p className="mt-1 font-medium">{auditDetailEntry.entityType}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">{t('settings.audit_timestamp')}</Label>
|
||||||
|
<p className="mt-1 text-xs">{new Date(auditDetailEntry.timestamp).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">ID</Label>
|
||||||
|
<p className="mt-1 text-xs font-mono">{auditDetailEntry.entityId ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
{auditDetailEntry.ipAddress && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">{t('audit.ip_address')}</Label>
|
||||||
|
<p className="mt-1 text-xs">{auditDetailEntry.ipAddress}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{auditDetailEntry.userAgent && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">{t('audit.user_agent')}</Label>
|
||||||
|
<p className="mt-1 text-xs truncate max-w-[200px]">{auditDetailEntry.userAgent}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Changes before/after */}
|
||||||
|
{auditDetailEntry.changes && (() => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(auditDetailEntry.changes);
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{parsed.before && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] uppercase tracking-wider text-rose-500 font-semibold">{t('audit.before')}</Label>
|
||||||
|
<pre className="mt-1 p-2 rounded-lg bg-rose-50 dark:bg-rose-950/20 text-xs text-rose-700 dark:text-rose-300 overflow-auto max-h-40 font-mono whitespace-pre-wrap">
|
||||||
|
{JSON.stringify(parsed.before, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parsed.after && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] uppercase tracking-wider text-emerald-500 font-semibold">{t('audit.after')}</Label>
|
||||||
|
<pre className="mt-1 p-2 rounded-lg bg-emerald-50 dark:bg-emerald-950/20 text-xs text-emerald-700 dark:text-emerald-300 overflow-auto max-h-40 font-mono whitespace-pre-wrap">
|
||||||
|
{JSON.stringify(parsed.after, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return <p className="text-xs text-gray-500">{t('audit.no_changes')}</p>;
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ── Data Management Tab ─────────────────────────────── */}
|
{/* ── Data Management Tab ─────────────────────────────── */}
|
||||||
@@ -2523,6 +2850,364 @@ export default function SettingsView() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* ── District Tab (SUPER_ADMIN only) ─────────────────────────── */}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<TabsContent value="district">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* District Management Card */}
|
||||||
|
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
|
||||||
|
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
|
||||||
|
<Building2 className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
{t('district.title')}
|
||||||
|
</CardTitle>
|
||||||
|
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
|
||||||
|
<Button
|
||||||
|
className="bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 text-white shadow-md shadow-violet-300/20 rounded-xl min-h-[44px]"
|
||||||
|
onClick={() => { resetDistrictForm(); setEditingDistrict(null); setDistrictDialogOpen(true); }}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1.5" />
|
||||||
|
{t('district.create')}
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{districtsLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-xl" />)}
|
||||||
|
</div>
|
||||||
|
) : districts.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-violet-100 to-purple-100 dark:from-violet-900/30 dark:to-purple-900/30 mx-auto mb-5 shadow-md shadow-violet-200/40 dark:shadow-violet-900/20">
|
||||||
|
<Building2 className="h-10 w-10 text-violet-500 dark:text-violet-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('district.no_districts')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-md mx-auto">{t('district.no_districts_desc')}</p>
|
||||||
|
<Button className="mt-5 bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 text-white rounded-xl min-h-[44px]" onClick={() => { resetDistrictForm(); setEditingDistrict(null); setDistrictDialogOpen(true); }}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
{t('district.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{districts.map((district) => (
|
||||||
|
<motion.div
|
||||||
|
key={district.id}
|
||||||
|
whileHover={{ scale: 1.01 }}
|
||||||
|
className="district-card p-5 bg-white dark:bg-gray-950 border border-gray-100 dark:border-gray-800 cursor-pointer"
|
||||||
|
onClick={() => loadDistrictSchoolsData(district.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-violet-400 to-purple-500 text-white shadow-sm">
|
||||||
|
<Building2 className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm truncate">{district.name}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
{district.code && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">{district.code}</span>
|
||||||
|
)}
|
||||||
|
{district.region && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-0.5">
|
||||||
|
<MapPin className="h-3 w-3" />{district.region}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Badge className="bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 text-[10px] px-1.5 py-0 rounded-xl">
|
||||||
|
{t('district.school_count')}: {district.schools.length}
|
||||||
|
</Badge>
|
||||||
|
{district.isActive ? (
|
||||||
|
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-[10px] px-1.5 py-0 rounded-xl">
|
||||||
|
{t('schedules.active')}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-gray-100 text-gray-500 dark:bg-gray-900/30 dark:text-gray-400 text-[10px] px-1.5 py-0 rounded-xl">
|
||||||
|
{t('schedules.inactive')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="min-h-[44px] min-w-[44px] rounded-xl text-gray-400 hover:text-violet-500"
|
||||||
|
onClick={(e) => { e.stopPropagation(); openEditDistrict(district); }}
|
||||||
|
aria-label={t('action.edit')}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="min-h-[44px] min-w-[44px] rounded-xl text-gray-400 hover:text-rose-500"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setDeleteDistrictId(district.id); }}
|
||||||
|
aria-label={t('action.delete')}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* District Schools Detail */}
|
||||||
|
{selectedDistrictId && (
|
||||||
|
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
|
||||||
|
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
|
||||||
|
<CardTitle className="text-base font-bold flex items-center gap-2">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
|
||||||
|
<School className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
{t('district.schools')}
|
||||||
|
{districts.find((d) => d.id === selectedDistrictId) && (
|
||||||
|
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||||
|
· {districts.find((d) => d.id === selectedDistrictId)!.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Assign school */}
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="space-y-1 min-w-[200px]">
|
||||||
|
<Label className="text-sm font-medium">{t('district.assign_school')}</Label>
|
||||||
|
<Select value={assignSchoolId} onValueChange={setAssignSchoolId}>
|
||||||
|
<SelectTrigger className="rounded-xl border-violet-200/50 dark:border-violet-900/30">
|
||||||
|
<SelectValue placeholder={t('district.select_school')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{schools
|
||||||
|
.filter((s) => !districtSchools.find((ds) => ds.id === s.id))
|
||||||
|
.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 text-white rounded-xl min-h-[44px]"
|
||||||
|
onClick={handleAssignSchool}
|
||||||
|
disabled={!assignSchoolId}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
{t('district.assign_school')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Schools list */}
|
||||||
|
{districtSchoolsLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-20 rounded-xl" />)}
|
||||||
|
</div>
|
||||||
|
) : districtSchools.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<School className="h-8 w-8 text-violet-400 dark:text-violet-500 mx-auto mb-2" />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">{t('district.no_schools_assigned')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-96 overflow-y-auto scrollbar-education">
|
||||||
|
{districtSchools.map((school) => (
|
||||||
|
<div key={school.id} className="p-4 rounded-xl bg-gradient-to-r from-gray-50 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border-l-3 border-l-violet-400/40 hover:border-l-violet-500 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
|
||||||
|
<School className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm">{school.name}</p>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>{t('district.total_students')}: {school._count.students}</span>
|
||||||
|
<span>{t('district.total_classes')}: {school._count.classGroups}</span>
|
||||||
|
<span>{t('district.total_teachers')}: {school._count.users}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="min-h-[44px] min-w-[44px] text-gray-400 hover:text-rose-500 rounded-xl"
|
||||||
|
onClick={() => handleUnassignSchool(school.id)}
|
||||||
|
aria-label={t('district.unassign_school')}
|
||||||
|
>
|
||||||
|
<ArrowRightLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cross-School Comparison */}
|
||||||
|
{districts.length > 0 && (
|
||||||
|
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-purple-500 overflow-hidden">
|
||||||
|
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-purple-50/50 to-transparent dark:from-purple-900/10 dark:to-transparent">
|
||||||
|
<CardTitle className="text-base font-bold flex items-center gap-2">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400">
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
{t('district.cross_school_comparison')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="cross-school-comparison">
|
||||||
|
{districts.map((district) => {
|
||||||
|
const totalStudents = district.schools.length;
|
||||||
|
const maxSchools = Math.max(...districts.map((d) => d.schools.length), 1);
|
||||||
|
const pct = Math.round((totalStudents / maxSchools) * 100);
|
||||||
|
return (
|
||||||
|
<div key={district.id} className="p-4 rounded-xl bg-gradient-to-r from-gray-50 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm">{district.name}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge className="bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 text-[10px] px-1.5 py-0 rounded-xl">
|
||||||
|
{district.schools.length} {t('district.schools')}
|
||||||
|
</Badge>
|
||||||
|
{totalStudents === maxSchools && districts.length > 1 && (
|
||||||
|
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-[10px] px-1.5 py-0 rounded-xl">
|
||||||
|
<ArrowUpRight className="h-3 w-3 mr-0.5" />
|
||||||
|
{t('district.top_performing')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="comparison-bar">
|
||||||
|
<div
|
||||||
|
className="comparison-bar-fill bg-gradient-to-r from-violet-400 to-purple-500"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── District Create/Edit Dialog ─────────────────────────────── */}
|
||||||
|
<Dialog open={districtDialogOpen} onOpenChange={(open) => { if (!open) { setDistrictDialogOpen(false); setEditingDistrict(null); } }}>
|
||||||
|
<DialogContent className="rounded-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-lg font-bold">
|
||||||
|
{editingDistrict ? t('district.edit') : t('district.create')}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-5 pt-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">{t('district.name')}</Label>
|
||||||
|
<Input
|
||||||
|
value={districtForm.name}
|
||||||
|
onChange={(e) => setDistrictForm({ ...districtForm, name: e.target.value })}
|
||||||
|
className="rounded-xl border-violet-200/50 dark:border-violet-900/30"
|
||||||
|
placeholder={t('district.name')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">{t('district.code')}</Label>
|
||||||
|
<Input
|
||||||
|
value={districtForm.code}
|
||||||
|
onChange={(e) => setDistrictForm({ ...districtForm, code: e.target.value })}
|
||||||
|
className="rounded-xl border-violet-200/50 dark:border-violet-900/30"
|
||||||
|
placeholder="DE-NW-001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">{t('district.region')}</Label>
|
||||||
|
<Input
|
||||||
|
value={districtForm.region}
|
||||||
|
onChange={(e) => setDistrictForm({ ...districtForm, region: e.target.value })}
|
||||||
|
className="rounded-xl border-violet-200/50 dark:border-violet-900/30"
|
||||||
|
placeholder="Nordrhein-Westfalen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">{t('district.country')}</Label>
|
||||||
|
<Input
|
||||||
|
value={districtForm.country}
|
||||||
|
onChange={(e) => setDistrictForm({ ...districtForm, country: e.target.value })}
|
||||||
|
className="rounded-xl border-violet-200/50 dark:border-violet-900/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">{t('district.admin_email')}</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={districtForm.adminEmail}
|
||||||
|
onChange={(e) => setDistrictForm({ ...districtForm, adminEmail: e.target.value })}
|
||||||
|
className="rounded-xl border-violet-200/50 dark:border-violet-900/30"
|
||||||
|
placeholder="admin@bezirk.de"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => { setDistrictDialogOpen(false); setEditingDistrict(null); }} className="rounded-xl">{t('action.cancel')}</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 text-white rounded-xl shadow-md min-h-[44px]"
|
||||||
|
onClick={editingDistrict ? handleUpdateDistrict : handleCreateDistrict}
|
||||||
|
disabled={districtSaving || !districtForm.name}
|
||||||
|
>
|
||||||
|
{districtSaving ? t('empty.loading') : (editingDistrict ? t('action.save') : t('district.create'))}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── District Delete Confirmation ────────────────────────────── */}
|
||||||
|
<AlertDialog open={!!deleteDistrictId} onOpenChange={(open) => { if (!open) setDeleteDistrictId(null); }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('district.confirm_delete')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t('district.confirm_delete_desc')}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t('action.cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => deleteDistrictId && handleDeleteDistrict(deleteDistrictId)} className="bg-rose-600 hover:bg-rose-700 text-white">
|
||||||
|
{t('action.delete')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* ── Rate Limit Tab ─────────────────────────────────────────── */}
|
||||||
|
<TabsContent value="rate-limit">
|
||||||
|
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-rose-500 overflow-hidden">
|
||||||
|
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-rose-50/50 to-transparent dark:from-rose-900/10 dark:to-transparent">
|
||||||
|
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
{t('rate_limit.status_title')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t('rate_limit.auth_limit')} · {t('rate_limit.data_limit')} · {t('rate_limit.write_limit')} · {t('rate_limit.heavy_limit')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RateLimitStatus />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+279
-2
@@ -10,11 +10,27 @@ async function apiRequest<T>(url: string, options?: RequestInit): Promise<T> {
|
|||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({}));
|
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}`);
|
throw new Error(body.error || `API error ${res.status}`);
|
||||||
}
|
}
|
||||||
return res.json();
|
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> {
|
export function apiGet<T>(url: string): Promise<T> {
|
||||||
return apiRequest<T>(url);
|
return apiRequest<T>(url);
|
||||||
}
|
}
|
||||||
@@ -703,23 +719,143 @@ export interface AuditLogEntry {
|
|||||||
action: string;
|
action: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
entityId: string | null;
|
entityId: string | null;
|
||||||
|
changes: string | null;
|
||||||
|
ipAddress: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
metadata: string | null;
|
metadata: string | null;
|
||||||
user: { id: string; firstName: string; lastName: 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?: {
|
export function fetchAuditLog(params?: {
|
||||||
schoolId?: string;
|
schoolId?: string;
|
||||||
action?: string;
|
action?: string;
|
||||||
|
entityType?: string;
|
||||||
|
userId?: string;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
}): Promise<AuditLogEntry[]> {
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<AuditLogPaginatedResponse> {
|
||||||
const sp = new URLSearchParams();
|
const sp = new URLSearchParams();
|
||||||
if (params?.schoolId) sp.set('schoolId', params.schoolId);
|
if (params?.schoolId) sp.set('schoolId', params.schoolId);
|
||||||
if (params?.action) sp.set('action', params.action);
|
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?.startDate) sp.set('startDate', params.startDate);
|
||||||
if (params?.endDate) sp.set('endDate', params.endDate);
|
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) ────────────────────────────────────────────── */
|
/* ── Data Export (CSV) ────────────────────────────────────────────── */
|
||||||
@@ -2447,3 +2583,144 @@ export function bulkCreateStudentAccounts(data: {
|
|||||||
}): Promise<{ created: Array<Record<string, unknown>>; count: number }> {
|
}): Promise<{ created: Array<Record<string, unknown>>; count: number }> {
|
||||||
return apiPost<{ created: Array<Record<string, unknown>>; count: number }>('/api/users', { ...data, action: 'bulkCreateStudents' });
|
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.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.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
|
// Curriculum Coverage — Kompetenzabdeckung
|
||||||
'nav.coverage': 'Abdeckung',
|
'nav.coverage': 'Abdeckung',
|
||||||
'coverage.title': 'Kompetenzabdeckung',
|
'coverage.title': 'Kompetenzabdeckung',
|
||||||
@@ -2327,6 +2414,117 @@ const de: TranslationDict = {
|
|||||||
'performance.title': 'Leistung',
|
'performance.title': 'Leistung',
|
||||||
'performance.fps': 'Bilder pro Sekunde',
|
'performance.fps': 'Bilder pro Sekunde',
|
||||||
'mobile.optimized': 'Für mobile Geräte optimiert',
|
'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 = {
|
const en: TranslationDict = {
|
||||||
@@ -3714,6 +3912,93 @@ const en: TranslationDict = {
|
|||||||
'behavior.duplicate_category': 'A category with this name already exists',
|
'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.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
|
// Curriculum Coverage
|
||||||
'nav.coverage': 'Coverage',
|
'nav.coverage': 'Coverage',
|
||||||
'coverage.title': 'Curriculum Coverage',
|
'coverage.title': 'Curriculum Coverage',
|
||||||
@@ -4653,6 +4938,117 @@ const en: TranslationDict = {
|
|||||||
'performance.title': 'Leistung',
|
'performance.title': 'Leistung',
|
||||||
'performance.fps': 'Bilder pro Sekunde',
|
'performance.fps': 'Bilder pro Sekunde',
|
||||||
'mobile.optimized': 'Für mobile Geräte optimiert',
|
'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 };
|
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();
|
||||||
|
}
|
||||||
+85
@@ -1,5 +1,62 @@
|
|||||||
# CompetenceTrack — Project Worklog
|
# CompetenceTrack — Project Worklog
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: 12
|
||||||
|
Agent: behavior-audit
|
||||||
|
Task: Behavior Tracking Enhancement + Audit Logging
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- Added BehaviorIntervention Prisma model with fields: type, description, status, assignedTo, startDate, endDate, outcome, incidentId, isDemo, deletedAt
|
||||||
|
- Added relations to School, Student, BehaviorIncident, and User models
|
||||||
|
- Enhanced AuditLog model with new fields: changes (JSON before/after), ipAddress, userAgent
|
||||||
|
- Ran db:push to sync schema changes
|
||||||
|
- Created audit logging middleware (src/lib/audit.ts) with logAudit() and withAuditLog() helper functions
|
||||||
|
- Created Intervention API routes: GET/POST /api/behavior-interventions, GET/PUT/DELETE /api/behavior-interventions/[id]
|
||||||
|
- Enhanced Audit Log API with pagination, entityType filter, userId filter, and CSV export endpoint
|
||||||
|
- Added API client functions: fetchBehaviorInterventions, createBehaviorIntervention, updateBehaviorIntervention, deleteBehaviorIntervention, exportAuditLogCsv
|
||||||
|
- Enhanced AuditLogEntry type with changes, ipAddress, userAgent fields
|
||||||
|
- Added fetchAuditLog to return paginated response (AuditLogPaginatedResponse)
|
||||||
|
- Added "Analytics" tab to behavior-tracking-view.tsx with:
|
||||||
|
- Line chart: trend of positive vs negative incidents over time (by week)
|
||||||
|
- Pie chart: category distribution breakdown
|
||||||
|
- Bar chart: time-of-day analysis showing when incidents most frequently occur
|
||||||
|
- Horizontal stacked bar chart: class comparison across classes
|
||||||
|
- Risk profile: students with frequent negative incidents (high/medium/low risk)
|
||||||
|
- Positive reinforcement: students with positive behavior trends
|
||||||
|
- Period selector: last 30 days, last 90 days, this school year
|
||||||
|
- Added "Interventions" tab to behavior-tracking-view.tsx with:
|
||||||
|
- Intervention CRUD: create, edit, delete interventions
|
||||||
|
- Status tracking with progress indicators (planned/in_progress/completed/cancelled)
|
||||||
|
- Type filter and status filter
|
||||||
|
- Quick status change buttons (planned -> in_progress -> completed)
|
||||||
|
- Effectiveness outcome display for completed interventions
|
||||||
|
- Linked incident display
|
||||||
|
- Assigned user display
|
||||||
|
- Intervention form dialog with all fields
|
||||||
|
- Enhanced settings-view.tsx audit tab with:
|
||||||
|
- Entity type filter chips
|
||||||
|
- Pagination (previous/next page controls)
|
||||||
|
- CSV export button
|
||||||
|
- Click-to-view detail dialog showing before/after changes
|
||||||
|
- IP address display in timeline entries
|
||||||
|
- User agent display in detail dialog
|
||||||
|
- Added 150+ new i18n keys (DE + EN) for behavior analytics, interventions, and audit log enhancement
|
||||||
|
- Lint passes with 0 errors
|
||||||
|
|
||||||
|
Stage Summary:
|
||||||
|
- New Prisma model: BehaviorIntervention (behavior_interventions table)
|
||||||
|
- Enhanced AuditLog model with changes, ipAddress, userAgent fields
|
||||||
|
- New file: src/lib/audit.ts (audit logging middleware)
|
||||||
|
- New files: src/app/api/behavior-interventions/route.ts, src/app/api/behavior-interventions/[id]/route.ts
|
||||||
|
- Modified: src/app/api/audit-log/route.ts (pagination, filters, CSV export)
|
||||||
|
- Modified: src/lib/api.ts (intervention API functions, enhanced audit log types)
|
||||||
|
- Modified: src/lib/i18n.ts (150+ new DE/EN keys)
|
||||||
|
- Modified: src/components/behavior-tracking-view.tsx (analytics tab + interventions tab)
|
||||||
|
- Modified: src/components/settings-view.tsx (enhanced audit tab with pagination, filters, export, detail view)
|
||||||
|
- Modified: prisma/schema.prisma (BehaviorIntervention model + AuditLog enhancements)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Current Project Status (Round 14 — Lint Cleanup, ESLint Config Fix, Hook Violation Fix, WS Service Restart)
|
## Current Project Status (Round 14 — Lint Cleanup, ESLint Config Fix, Hook Violation Fix, WS Service Restart)
|
||||||
|
|
||||||
**Status**: Stable, all features working, lint passes with 0 errors
|
**Status**: Stable, all features working, lint passes with 0 errors
|
||||||
@@ -458,3 +515,31 @@ See git history for details on earlier rounds.
|
|||||||
- App running on localhost:3000 (200 OK)
|
- App running on localhost:3000 (200 OK)
|
||||||
- Backup API responding correctly
|
- Backup API responding correctly
|
||||||
- Prisma schema pushed successfully
|
- Prisma schema pushed successfully
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: 11
|
||||||
|
Agent: offline-pwa-rate-limit
|
||||||
|
Task: Offline Mode (PWA) + API Rate Limiting
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- Created Service Worker at public/sw.js with NetworkFirst (API), CacheFirst (static), navigation fallback, background sync, IndexedDB for pending requests, push notification support
|
||||||
|
- Created Web App Manifest at public/manifest.json with emerald theme, standalone display, German description
|
||||||
|
- Generated PWA icons (icon-192.png, icon-512.png) using sharp from SVG with emerald-teal gradient and book icon
|
||||||
|
- Updated src/app/layout.tsx with manifest metadata, appleWebApp config, Viewport export with themeColor, apple-touch-icon link
|
||||||
|
- Created src/components/offline-indicator.tsx with OfflineIndicator bar, PWAInstallPrompt card, useServiceWorker hook, OfflineBadge, OfflineSyncManager, showRateLimitToast, RateLimitStatus component
|
||||||
|
- Created src/lib/offline-cache.ts with localStorage notebook caching (cacheNotebook, getCachedNotebook, queueNotebookEdit, replayQueuedEdits)
|
||||||
|
- Integrated offline components in src/components/app-layout.tsx (SW registration, OfflineIndicator, PWAInstallPrompt, OfflineSyncManager)
|
||||||
|
- Created src/lib/rate-limit.ts with in-memory rate limiting (Map-based), configurable presets (auth:5/min, dataRead:60/min, dataWrite:30/min, heavy:10/5min), withRateLimit wrapper, rate limit headers, 429 response, stats endpoint
|
||||||
|
- Applied rate limiting to /api/auth (auth preset), /api/data-export/csv (heavy), /api/reports/pdf (heavy), /api/data-import (heavy), /api/backup (heavy)
|
||||||
|
- Created /api/rate-limit-stats API endpoint for admin monitoring
|
||||||
|
- Updated src/lib/api.ts with 429 handling and RateLimitError interface
|
||||||
|
- Added RateLimitStatus tab in settings-view.tsx with Shield icon and rose color scheme
|
||||||
|
- Added 40 i18n keys (32 PWA/offline + 8 rate limit) in DE and EN
|
||||||
|
- Added offline/PWA/rate-limit CSS styles in globals.css
|
||||||
|
|
||||||
|
Stage Summary:
|
||||||
|
- Full PWA support with offline mode, service worker, install prompt, and offline indicator
|
||||||
|
- Notebook data cached in localStorage for offline access with queued edits for sync
|
||||||
|
- API rate limiting with configurable presets protecting auth, data, and heavy operation endpoints
|
||||||
|
- Rate limit admin monitoring in settings view
|
||||||
|
- 8 new files, 11 modified files, 0 lint errors
|
||||||
Reference in new issue
Block a user