feat: complete refactoring phase 1 with API, component, and business logic consolidation
Co-authored-by: cetinmustafa83 <30570791+cetinmustafa83@users.noreply.github.com>
This commit is contained in:
17 files changed
+3061
-1
No files matched your search
@@ -0,0 +1,119 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* Standardized API Response Format
|
||||
* All routes should use these helpers for consistency
|
||||
*/
|
||||
|
||||
export interface StandardResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Success response with data
|
||||
*/
|
||||
export const successResponse = <T = any>(
|
||||
data: T,
|
||||
status = 200,
|
||||
path?: string
|
||||
): NextResponse<StandardResponse<T>> => {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
path,
|
||||
},
|
||||
{ status }
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Error response
|
||||
*/
|
||||
export const errorResponse = (
|
||||
error: string | Error,
|
||||
status = 400,
|
||||
path?: string
|
||||
): NextResponse<StandardResponse> => {
|
||||
const message = error instanceof Error ? error.message : error;
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: message,
|
||||
timestamp: new Date().toISOString(),
|
||||
path,
|
||||
},
|
||||
{ status }
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Created (201) response for POST
|
||||
*/
|
||||
export const createdResponse = <T = any>(
|
||||
data: T,
|
||||
path?: string
|
||||
): NextResponse<StandardResponse<T>> => {
|
||||
return successResponse(data, 201, path);
|
||||
};
|
||||
|
||||
/**
|
||||
* No content (204) response for DELETE
|
||||
*/
|
||||
export const noContentResponse = () => {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
};
|
||||
|
||||
/**
|
||||
* Unauthorized error
|
||||
*/
|
||||
export const unauthorizedError = (path?: string) => {
|
||||
return errorResponse('Unauthorized', 401, path);
|
||||
};
|
||||
|
||||
/**
|
||||
* Forbidden error
|
||||
*/
|
||||
export const forbiddenError = (path?: string) => {
|
||||
return errorResponse('Forbidden', 403, path);
|
||||
};
|
||||
|
||||
/**
|
||||
* Not found error
|
||||
*/
|
||||
export const notFoundError = (message = 'Not found', path?: string) => {
|
||||
return errorResponse(message, 404, path);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validation error
|
||||
*/
|
||||
export const validationError = (message: string, path?: string) => {
|
||||
return errorResponse(message, 422, path);
|
||||
};
|
||||
|
||||
/**
|
||||
* Server error
|
||||
*/
|
||||
export const serverError = (error: Error | string, path?: string) => {
|
||||
return errorResponse(error, 500, path);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap handler with automatic error handling
|
||||
*/
|
||||
export const withErrorHandling = (handler: Function) => {
|
||||
return async (...args: any[]) => {
|
||||
try {
|
||||
return await handler(...args);
|
||||
} catch (error) {
|
||||
console.error('[v0] API Error:', error);
|
||||
return serverError(error instanceof Error ? error : 'Unknown error');
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Unified Grading Engine
|
||||
* Consolidates all grading logic from:
|
||||
* - grading-view.tsx
|
||||
* - tablet-grading-view.tsx
|
||||
* - grade-analytics-view.tsx
|
||||
* - ai-grading-audit logic
|
||||
*/
|
||||
|
||||
export type GradeScale = 1 | 2 | 3 | 4 | 5 | 6; // German grading scale
|
||||
export type MasteryLevel = 'emerging' | 'developing' | 'proficient' | 'advanced';
|
||||
|
||||
export interface GradingConfig {
|
||||
scaleType: 'numeric' | 'competency' | 'points'; // numeric=1-6, competency=E/D/P/A, points=0-100
|
||||
maxScore?: number; // for point-based grading
|
||||
masteryLevels?: MasteryLevel[];
|
||||
weights?: Record<string, number>; // for weighted grading
|
||||
passingScore?: number; // default 4 for numeric, configurable otherwise
|
||||
}
|
||||
|
||||
export interface GradeInput {
|
||||
score: number | GradeScale | MasteryLevel;
|
||||
maxScore?: number;
|
||||
studentId: string;
|
||||
assessmentId: string;
|
||||
timestamp: Date;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface GradeOutput {
|
||||
originalScore: number | GradeScale | MasteryLevel;
|
||||
normalizedScore: number; // 0-100 for comparison
|
||||
grade: GradeScale | MasteryLevel; // final grade
|
||||
isPassing: boolean;
|
||||
feedback: string;
|
||||
competencyMap?: Record<string, MasteryLevel>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize different grading scales to 0-100
|
||||
*/
|
||||
export function normalizeScore(
|
||||
score: number | GradeScale | MasteryLevel,
|
||||
config: GradingConfig
|
||||
): number {
|
||||
if (typeof score === 'string') {
|
||||
// Competency level to percentage
|
||||
const masteryMap: Record<MasteryLevel, number> = {
|
||||
emerging: 25,
|
||||
developing: 50,
|
||||
proficient: 75,
|
||||
advanced: 100,
|
||||
};
|
||||
return masteryMap[score as MasteryLevel] || 0;
|
||||
}
|
||||
|
||||
if (config.scaleType === 'numeric') {
|
||||
// German scale (1-6) to percentage: 1=100%, 6=0%
|
||||
return ((7 - (score as number)) / 6) * 100;
|
||||
}
|
||||
|
||||
if (config.scaleType === 'points') {
|
||||
// Point-based to percentage
|
||||
const max = config.maxScore || 100;
|
||||
return ((score as number) / max) * 100;
|
||||
}
|
||||
|
||||
return score as number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert normalized score back to target scale
|
||||
*/
|
||||
export function denormalizeScore(
|
||||
normalized: number,
|
||||
config: GradingConfig
|
||||
): GradeScale | MasteryLevel | number {
|
||||
if (config.scaleType === 'numeric') {
|
||||
// Percentage back to German scale
|
||||
const scale = Math.round((100 - normalized) / (100 / 6)) + 1;
|
||||
return Math.max(1, Math.min(6, scale)) as GradeScale;
|
||||
}
|
||||
|
||||
if (config.scaleType === 'competency') {
|
||||
if (normalized >= 75) return 'advanced';
|
||||
if (normalized >= 50) return 'proficient';
|
||||
if (normalized >= 25) return 'developing';
|
||||
return 'emerging';
|
||||
}
|
||||
|
||||
if (config.scaleType === 'points') {
|
||||
const max = config.maxScore || 100;
|
||||
return Math.round((normalized / 100) * max);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if grade is passing
|
||||
*/
|
||||
export function isPassing(
|
||||
score: number | GradeScale | MasteryLevel,
|
||||
config: GradingConfig
|
||||
): boolean {
|
||||
const normalized = normalizeScore(score, config);
|
||||
const passingPercentage =
|
||||
config.passingScore !== undefined ? config.passingScore : 50;
|
||||
|
||||
return normalized >= passingPercentage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate feedback message based on performance
|
||||
*/
|
||||
export function generateFeedback(
|
||||
normalized: number,
|
||||
config: GradingConfig
|
||||
): string {
|
||||
const feedbackMap: Record<string, string> = {
|
||||
excellent: 'Ausgezeichnete Leistung! Weiterhin so gut!',
|
||||
good: 'Gute Leistung. Du machst Fortschritte!',
|
||||
satisfactory: 'Befriedigende Leistung. Einige Bereiche können verbessert werden.',
|
||||
adequate: 'Ausreichende Leistung. Mehr Anstrengung ist erforderlich.',
|
||||
poor: 'Unzureichende Leistung. Zusätzliche Unterstützung wird empfohlen.',
|
||||
};
|
||||
|
||||
if (normalized >= 90) return feedbackMap.excellent;
|
||||
if (normalized >= 75) return feedbackMap.good;
|
||||
if (normalized >= 60) return feedbackMap.satisfactory;
|
||||
if (normalized >= 50) return feedbackMap.adequate;
|
||||
return feedbackMap.poor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a grade with full analysis
|
||||
*/
|
||||
export function processGrade(
|
||||
input: GradeInput,
|
||||
config: GradingConfig
|
||||
): GradeOutput {
|
||||
const normalized = normalizeScore(input.score, config);
|
||||
const grade = denormalizeScore(normalized, config);
|
||||
const passing = isPassing(input.score, config);
|
||||
const feedback = generateFeedback(normalized, config);
|
||||
|
||||
return {
|
||||
originalScore: input.score,
|
||||
normalizedScore: normalized,
|
||||
grade: grade as GradeScale | MasteryLevel,
|
||||
isPassing: passing,
|
||||
feedback,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate weighted grade from multiple components
|
||||
*/
|
||||
export function calculateWeightedGrade(
|
||||
components: Array<{
|
||||
name: string;
|
||||
score: number | GradeScale | MasteryLevel;
|
||||
weight: number;
|
||||
}>,
|
||||
config: GradingConfig
|
||||
): GradeOutput {
|
||||
let totalWeight = 0;
|
||||
let weightedSum = 0;
|
||||
|
||||
for (const comp of components) {
|
||||
const normalized = normalizeScore(comp.score, config);
|
||||
const weight = config.weights?.[comp.name] || comp.weight || 1;
|
||||
|
||||
weightedSum += normalized * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
|
||||
const finalNormalized = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
||||
const grade = denormalizeScore(finalNormalized, config);
|
||||
const passing = isPassing(grade, config);
|
||||
const feedback = generateFeedback(finalNormalized, config);
|
||||
|
||||
return {
|
||||
originalScore: components.map((c) => c.score),
|
||||
normalizedScore: finalNormalized,
|
||||
grade: grade as GradeScale | MasteryLevel,
|
||||
isPassing: passing,
|
||||
feedback,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare grades (for analytics)
|
||||
*/
|
||||
export function compareGrades(
|
||||
grade1: number | GradeScale | MasteryLevel,
|
||||
grade2: number | GradeScale | MasteryLevel,
|
||||
config: GradingConfig
|
||||
): number {
|
||||
const norm1 = normalizeScore(grade1, config);
|
||||
const norm2 = normalizeScore(grade2, config);
|
||||
return norm2 - norm1; // positive = improvement, negative = decline
|
||||
}
|
||||
|
||||
/**
|
||||
* Get grading statistics
|
||||
*/
|
||||
export function getStatistics(
|
||||
grades: Array<number | GradeScale | MasteryLevel>,
|
||||
config: GradingConfig
|
||||
) {
|
||||
const normalized = grades.map((g) => normalizeScore(g, config));
|
||||
|
||||
const sum = normalized.reduce((a, b) => a + b, 0);
|
||||
const avg = sum / normalized.length;
|
||||
const sorted = [...normalized].sort((a, b) => a - b);
|
||||
const median = sorted[Math.floor(sorted.length / 2)];
|
||||
const min = Math.min(...normalized);
|
||||
const max = Math.max(...normalized);
|
||||
const stdDev = Math.sqrt(
|
||||
normalized.reduce((sq, n) => sq + Math.pow(n - avg, 2), 0) / normalized.length
|
||||
);
|
||||
|
||||
return {
|
||||
average: avg,
|
||||
median,
|
||||
min,
|
||||
max,
|
||||
stdDev,
|
||||
count: grades.length,
|
||||
passingCount: grades.filter((g) => isPassing(g, config)).length,
|
||||
passingPercentage:
|
||||
(grades.filter((g) => isPassing(g, config)).length / grades.length) * 100,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import useSWR, { SWRConfiguration } from 'swr';
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface UseApiOptions extends SWRConfiguration {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
body?: any;
|
||||
headers?: HeadersInit;
|
||||
onSuccess?: (data: any) => void;
|
||||
onError?: (error: any) => void;
|
||||
skip?: boolean;
|
||||
}
|
||||
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
|
||||
/**
|
||||
* Standardized API fetcher with error handling
|
||||
*/
|
||||
export const apiFetcher = async (
|
||||
url: string,
|
||||
options?: {
|
||||
method?: string;
|
||||
body?: any;
|
||||
headers?: HeadersInit;
|
||||
}
|
||||
): Promise<any> => {
|
||||
const method = options?.method || 'GET';
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(`${apiUrl}${url}`, {
|
||||
method,
|
||||
headers,
|
||||
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for fetching data with built-in caching (GET requests)
|
||||
* @param url - API endpoint
|
||||
* @param options - SWR options
|
||||
* @returns { data, error, isLoading, mutate }
|
||||
*/
|
||||
export function useApiGet<T = any>(
|
||||
url: string | null,
|
||||
options?: SWRConfiguration & { skip?: boolean }
|
||||
) {
|
||||
const { skip = false, ...swrOptions } = options || {};
|
||||
|
||||
const { data, error, isLoading, mutate } = useSWR<T>(
|
||||
skip || !url ? null : url,
|
||||
(url) => apiFetcher(url),
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 60000,
|
||||
...swrOptions,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
isLoading: isLoading && !error,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for mutations (POST, PUT, DELETE, PATCH)
|
||||
* @param url - API endpoint
|
||||
* @param options - Custom options including onSuccess, onError callbacks
|
||||
* @returns { mutate, isLoading, error, data }
|
||||
*/
|
||||
export function useApiMutation<T = any>(
|
||||
url: string,
|
||||
options?: UseApiOptions
|
||||
) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
|
||||
const mutate = useCallback(
|
||||
async (payload?: any) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}${url}`, {
|
||||
method: options?.method || 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
body: JSON.stringify(payload || options?.body),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
setData(result.data || result);
|
||||
options?.onSuccess?.(result.data || result);
|
||||
|
||||
return result.data || result;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
options?.onError?.(err);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[url, options]
|
||||
);
|
||||
|
||||
return { mutate, isLoading, error, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for complex queries with multiple dependencies
|
||||
* Useful for parallel requests
|
||||
*/
|
||||
export function useApiQueries<T = any>(
|
||||
queries: Array<{ url: string | null; key?: string }>,
|
||||
options?: SWRConfiguration
|
||||
) {
|
||||
const results = queries.map((query) => {
|
||||
const { data, error, isLoading, mutate } = useApiGet(query.url, options);
|
||||
return {
|
||||
key: query.key || query.url,
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
};
|
||||
});
|
||||
|
||||
const isLoading = results.some((r) => r.isLoading);
|
||||
const error = results.find((r) => r.error);
|
||||
const data = Object.fromEntries(results.map((r) => [r.key, r.data])) as Record<
|
||||
string,
|
||||
T | undefined
|
||||
>;
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
/**
|
||||
* Imperative API call (for one-off requests)
|
||||
*/
|
||||
export const apiCall = async <T = any>(
|
||||
url: string,
|
||||
options?: {
|
||||
method?: string;
|
||||
body?: any;
|
||||
headers?: HeadersInit;
|
||||
}
|
||||
): Promise<T> => {
|
||||
return apiFetcher(`${apiUrl}${url}`, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience methods
|
||||
*/
|
||||
export const apiGet = async <T = any>(url: string): Promise<T> => {
|
||||
return apiCall(url, { method: 'GET' });
|
||||
};
|
||||
|
||||
export const apiPost = async <T = any>(url: string, body?: any): Promise<T> => {
|
||||
return apiCall(url, { method: 'POST', body });
|
||||
};
|
||||
|
||||
export const apiPut = async <T = any>(url: string, body?: any): Promise<T> => {
|
||||
return apiCall(url, { method: 'PUT', body });
|
||||
};
|
||||
|
||||
export const apiDelete = async <T = any>(url: string, body?: any): Promise<T> => {
|
||||
return apiCall(url, { method: 'DELETE', body });
|
||||
};
|
||||
|
||||
export const apiPatch = async <T = any>(url: string, body?: any): Promise<T> => {
|
||||
return apiCall(url, { method: 'PATCH', body });
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { logComplianceAudit } from '@/lib/audit';
|
||||
import { successResponse, unauthorizedError, forbiddenError } from '@/lib/api-response';
|
||||
|
||||
export type RouteHandler = (
|
||||
request: NextRequest,
|
||||
context: any
|
||||
) => Promise<NextResponse>;
|
||||
|
||||
export interface RouteConfig {
|
||||
auth?: boolean; // Require authentication
|
||||
roles?: string[]; // Required roles
|
||||
audit?: boolean; // Log to audit trail
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware for authentication check
|
||||
*/
|
||||
export const withAuth = (handler: RouteHandler) => {
|
||||
return async (request: NextRequest, context: any) => {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return unauthorizedError(request.url);
|
||||
}
|
||||
// Attach user to request for use in handler
|
||||
(request as any).user = session.user;
|
||||
return handler(request, context);
|
||||
} catch (error) {
|
||||
console.error('[v0] Auth error:', error);
|
||||
return unauthorizedError(request.url);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware for role-based access control
|
||||
*/
|
||||
export const withRoles = (allowedRoles: string[]) => {
|
||||
return (handler: RouteHandler) => {
|
||||
return async (request: NextRequest, context: any) => {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return unauthorizedError(request.url);
|
||||
}
|
||||
|
||||
const userRole = session.user?.role;
|
||||
if (!allowedRoles.includes(userRole)) {
|
||||
return forbiddenError(request.url);
|
||||
}
|
||||
|
||||
(request as any).user = session.user;
|
||||
return handler(request, context);
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware for audit logging
|
||||
*/
|
||||
export const withAudit = (action: string) => {
|
||||
return (handler: RouteHandler) => {
|
||||
return async (request: NextRequest, context: any) => {
|
||||
const session = await getSession();
|
||||
const user = session?.user;
|
||||
|
||||
const response = await handler(request, context);
|
||||
|
||||
// Log after handler completes
|
||||
if (user) {
|
||||
try {
|
||||
await logComplianceAudit({
|
||||
userId: user.id,
|
||||
schoolId: user.schoolId,
|
||||
action,
|
||||
entityType: request.url.split('/').pop() || 'unknown',
|
||||
metadata: {
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[v0] Audit log error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware for request validation
|
||||
*/
|
||||
export const withValidation = (schema: any) => {
|
||||
return (handler: RouteHandler) => {
|
||||
return async (request: NextRequest, context: any) => {
|
||||
try {
|
||||
if (request.method === 'GET' || request.method === 'DELETE') {
|
||||
return handler(request, context);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const validation = schema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Validation error',
|
||||
details: validation.error.errors,
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
(request as any).validatedBody = validation.data;
|
||||
return handler(request, context);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid request body' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Combine multiple middleware
|
||||
*/
|
||||
export const withMiddleware =
|
||||
(...middleware: Array<(h: RouteHandler) => RouteHandler>) =>
|
||||
(handler: RouteHandler) => {
|
||||
return middleware.reduceRight((fn, mw) => mw(fn), handler);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example usage in a route:
|
||||
*
|
||||
* export const GET = withMiddleware(
|
||||
* withAuth,
|
||||
* withRoles(['TEACHER', 'ADMIN']),
|
||||
* withAudit('GET_STUDENTS')
|
||||
* )(async (request) => {
|
||||
* const user = (request as any).user;
|
||||
* // handler logic
|
||||
* });
|
||||
*/
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Signage utilities for emergency alerting system
|
||||
*/
|
||||
|
||||
export type MessageType = 'emergency' | 'notification' | 'schedule' | 'announcement' | 'alert';
|
||||
export type Priority = 'low' | 'normal' | 'high' | 'critical';
|
||||
|
||||
export interface SignageMessageData {
|
||||
title: string;
|
||||
content: string;
|
||||
messageType: MessageType;
|
||||
priority: Priority;
|
||||
displayDuration?: number;
|
||||
soundAlert?: boolean;
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
soundFile?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get priority color for UI
|
||||
*/
|
||||
export const getPriorityColor = (priority: Priority): string => {
|
||||
const colors: Record<Priority, string> = {
|
||||
low: '#10b981', // green
|
||||
normal: '#3b82f6', // blue
|
||||
high: '#f59e0b', // amber
|
||||
critical: '#ef4444', // red
|
||||
};
|
||||
return colors[priority] || colors.normal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get message type icon
|
||||
*/
|
||||
export const getMessageTypeIcon = (type: MessageType): string => {
|
||||
const icons: Record<MessageType, string> = {
|
||||
emergency: '🚨',
|
||||
notification: '📢',
|
||||
schedule: '📅',
|
||||
announcement: '📣',
|
||||
alert: '⚠️',
|
||||
};
|
||||
return icons[type] || '📢';
|
||||
};
|
||||
|
||||
/**
|
||||
* Format time display
|
||||
*/
|
||||
export const formatDisplayTime = (seconds: number): string => {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return secs > 0 ? `${minutes}m ${secs}s` : `${minutes}m`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if message is active
|
||||
*/
|
||||
export const isMessageActive = (message: {
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
}): boolean => {
|
||||
const now = new Date();
|
||||
if (message.startTime && now < message.startTime) return false;
|
||||
if (message.endTime && now > message.endTime) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get contrast color for text (black or white based on background)
|
||||
*/
|
||||
export const getContrastColor = (hexColor?: string): string => {
|
||||
if (!hexColor) return '#000000';
|
||||
|
||||
const r = parseInt(hexColor.slice(1, 3), 16);
|
||||
const g = parseInt(hexColor.slice(3, 5), 16);
|
||||
const b = parseInt(hexColor.slice(5, 7), 16);
|
||||
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.5 ? '#000000' : '#ffffff';
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate signage message
|
||||
*/
|
||||
export const validateSignageMessage = (message: Partial<SignageMessageData>): string[] => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!message.title || message.title.trim().length === 0) {
|
||||
errors.push('Title is required');
|
||||
}
|
||||
|
||||
if (!message.content || message.content.trim().length === 0) {
|
||||
errors.push('Content is required');
|
||||
}
|
||||
|
||||
if (!message.messageType) {
|
||||
errors.push('Message type is required');
|
||||
}
|
||||
|
||||
if (!message.priority) {
|
||||
errors.push('Priority is required');
|
||||
}
|
||||
|
||||
if (message.displayDuration && message.displayDuration < 1) {
|
||||
errors.push('Display duration must be at least 1 second');
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate emergency notification for school
|
||||
*/
|
||||
export const createEmergencyMessage = (
|
||||
incident: string,
|
||||
location: string,
|
||||
additionalInfo?: string
|
||||
): SignageMessageData => {
|
||||
return {
|
||||
title: 'NOTFALL MELDUNG',
|
||||
content: `${incident}\nOrt: ${location}${additionalInfo ? '\n' + additionalInfo : ''}`,
|
||||
messageType: 'emergency',
|
||||
priority: 'critical',
|
||||
displayDuration: 30,
|
||||
soundAlert: true,
|
||||
backgroundColor: '#ef4444', // red
|
||||
textColor: '#ffffff', // white
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get next active message based on schedule
|
||||
*/
|
||||
export const getNextScheduledMessage = (
|
||||
messages: Array<{
|
||||
id: string;
|
||||
displayOrder: number;
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
}>
|
||||
): string | null => {
|
||||
const activeMessages = messages
|
||||
.filter((m) => isMessageActive(m))
|
||||
.sort((a, b) => a.displayOrder - b.displayOrder);
|
||||
|
||||
return activeMessages.length > 0 ? activeMessages[0].id : null;
|
||||
};
|
||||
Reference in new issue
Block a user