Round 10: WYSIWYG Editor, Notification System, Calendar Events, Student UI, Drag-Reorder, Styling

This commit is contained in:
Mustafa Cetin committed 2026-07-29 08:49:40 +00:00
1 parent 62f4a150e0
commit b90ad85b91
18 files changed
+3105 -626

No files matched your search

+241 -116
View File
@@ -1,11 +1,8 @@
// CompetenceTrack — Notifications API
// CompetenceTrack — Notifications API (DB-backed)
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// In-memory store for read notification IDs (per server instance)
const readNotificationIds = new Set<string>();
export async function GET() {
const session = await getSession();
if (!session?.user) {
@@ -14,19 +11,31 @@ export async function GET() {
const userId = session.user.id;
const schoolId = session.user.schoolId;
const role = session.user.role;
if (!schoolId) {
return NextResponse.json({
upcomingAssessments: [],
missingObservations: [],
notifications: [],
unreadCount: 0,
});
}
// Fetch DB notifications for the current user
const notifications = await db.notification.findMany({
where: {
userId,
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
take: 50,
});
const unreadCount = notifications.filter((n) => !n.isRead).length;
// Also generate dynamic notifications from existing data
const now = new Date();
const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
const role = session.user.role;
// Get teacher's class IDs
const teacherClasses = await db.classGroupTeacher.findMany({
@@ -45,105 +54,138 @@ export async function GET() {
targetClassIds = schoolClasses.map((c) => c.id);
}
if (targetClassIds.length === 0) {
return NextResponse.json({
upcomingAssessments: [],
missingObservations: [],
unreadCount: 0,
});
}
// Upcoming assessments (next 7 days, not completed)
const upcomingAssessments = await db.assessment.findMany({
where: {
classGroupId: { in: targetClassIds },
date: {
gte: now,
lte: sevenDaysFromNow,
},
},
include: {
classGroup: { select: { name: true } },
subject: { select: { name: true } },
assessmentResults: { select: { id: true } },
},
orderBy: { date: 'asc' },
take: 10,
});
const assessmentNotifications = upcomingAssessments
.filter((a) => a.assessmentResults.length === 0)
.map((a) => {
const diffMs = new Date(a.date).getTime() - now.getTime();
const daysUntil = Math.max(0, Math.ceil(diffMs / (1000 * 60 * 60 * 24)));
const id = `assessment-${a.id}`;
return {
id,
type: 'assessment' as const,
title: a.title,
description: `${a.classGroup.name} · ${a.subject.name}`,
daysUntil,
timestamp: t('notifications.in_days', { days: String(daysUntil) }),
classGroupId: a.classGroupId,
subjectId: a.subjectId,
assessmentId: a.id,
isRead: readNotificationIds.has(id),
};
});
// Students with no progress entries in last 14 days
const activeEnrollments = await db.enrollment.findMany({
where: {
classGroupId: { in: targetClassIds },
endDate: null,
},
include: {
student: {
select: {
id: true,
firstName: true,
lastName: true,
learningProgressEntries: {
where: {
date: { gte: fourteenDaysAgo },
},
select: { id: true },
take: 1,
},
// For students, get their enrolled class IDs
if (role === 'STUDENT') {
const student = await db.student.findFirst({
where: { schoolId },
include: {
enrollments: {
where: { endDate: null },
select: { classGroupId: true },
},
},
classGroup: { select: { id: true, name: true } },
},
});
});
if (student) {
targetClassIds = student.enrollments.map((e) => e.classGroupId);
}
}
const missingObservations = activeEnrollments
.filter((e) => e.student.learningProgressEntries.length === 0)
.map((e) => {
const id = `missing-${e.student.id}-${e.classGroupId}`;
// Calculate days since last progress (use 14 as default since we filtered for 14 days)
return {
id,
type: 'missing_observation' as const,
title: `${e.student.firstName} ${e.student.lastName}`,
description: e.classGroup.name,
daysSince: 14,
timestamp: t('notifications.days_ago', { days: '14' }),
studentId: e.student.id,
classGroupId: e.classGroupId,
isRead: readNotificationIds.has(id),
};
// Upcoming assessments (next 7 days)
const upcomingAssessments: Array<{
id: string;
type: string;
title: string;
message: string;
isRead: boolean;
actionUrl: string | null;
relatedId: string | null;
createdAt: string;
}> = [];
if (targetClassIds.length > 0) {
const assessments = await db.assessment.findMany({
where: {
classGroupId: { in: targetClassIds },
date: {
gte: now,
lte: sevenDaysFromNow,
},
},
include: {
classGroup: { select: { name: true } },
subject: { select: { name: true } },
assessmentResults: { select: { id: true } },
},
orderBy: { date: 'asc' },
take: 10,
});
// Limit to 10 most relevant
const limitedMissing = missingObservations.slice(0, 10);
for (const a of assessments) {
const diffMs = new Date(a.date).getTime() - now.getTime();
const daysUntil = Math.max(0, Math.ceil(diffMs / (1000 * 60 * 60 * 24)));
// Check if we already have a DB notification for this assessment
const existing = notifications.find(
(n) => n.type === 'ASSESSMENT_DUE' && n.relatedId === a.id
);
if (!existing) {
upcomingAssessments.push({
id: `assessment-${a.id}`,
type: 'ASSESSMENT_DUE',
title: a.title,
message: `${a.classGroup.name} - ${a.subject.name}`,
isRead: false,
actionUrl: 'assessments',
relatedId: a.id,
createdAt: new Date().toISOString(),
});
}
}
const allItems = [...assessmentNotifications, ...limitedMissing];
const unreadCount = allItems.filter((n) => !n.isRead).length;
// Missing observations (students with no progress in 14 days) - only for teachers
if (role !== 'STUDENT') {
const activeEnrollments = await db.enrollment.findMany({
where: {
classGroupId: { in: targetClassIds },
endDate: null,
},
include: {
student: {
select: {
id: true,
firstName: true,
lastName: true,
learningProgressEntries: {
where: {
date: { gte: fourteenDaysAgo },
},
select: { id: true },
take: 1,
},
},
},
classGroup: { select: { id: true, name: true } },
},
});
for (const e of activeEnrollments) {
if (e.student.learningProgressEntries.length === 0) {
const existing = notifications.find(
(n) => n.type === 'MISSING_OBSERVATION' && n.relatedId === e.student.id
);
if (!existing) {
upcomingAssessments.push({
id: `missing-${e.student.id}-${e.classGroupId}`,
type: 'MISSING_OBSERVATION',
title: `${e.student.firstName} ${e.student.lastName}`,
message: e.classGroup.name,
isRead: false,
actionUrl: 'progress',
relatedId: e.student.id,
createdAt: new Date().toISOString(),
});
}
}
}
}
}
// Combine DB notifications with dynamic ones
const dbNotifications = notifications.map((n) => ({
id: n.id,
type: n.type,
title: n.title,
message: n.message,
isRead: n.isRead,
actionUrl: n.actionUrl,
relatedId: n.relatedId,
createdAt: n.createdAt.toISOString(),
}));
const allNotifications = [...dbNotifications, ...upcomingAssessments];
return NextResponse.json({
upcomingAssessments: assessmentNotifications,
missingObservations: limitedMissing,
unreadCount,
notifications: allNotifications,
unreadCount: allNotifications.filter((n) => !n.isRead).length,
});
}
@@ -155,12 +197,110 @@ export async function PUT(request: Request) {
try {
const body = await request.json();
const { ids, markAll } = body as { ids?: string[]; markAll?: boolean };
if (markAll) {
// Mark all notifications as read
await db.notification.updateMany({
where: {
userId: session.user.id,
isRead: false,
deletedAt: null,
},
data: { isRead: true },
});
return NextResponse.json({ success: true });
}
if (ids && Array.isArray(ids)) {
// Mark specific DB notifications as read
const dbIds = ids.filter((id) => !id.startsWith('assessment-') && !id.startsWith('missing-'));
if (dbIds.length > 0) {
await db.notification.updateMany({
where: {
id: { in: dbIds },
userId: session.user.id,
},
data: { isRead: true },
});
}
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}
export async function POST(request: Request) {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const body = await request.json();
const { schoolId, userId, type, title, message, actionUrl, relatedId } = body as {
schoolId: string;
userId: string;
type: string;
title: string;
message: string;
actionUrl?: string;
relatedId?: string;
};
if (!schoolId || !userId || !type || !title || !message) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const notification = await db.notification.create({
data: {
schoolId,
userId,
type,
title,
message,
actionUrl: actionUrl ?? null,
relatedId: relatedId ?? null,
},
});
return NextResponse.json({ notification }, { status: 201 });
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}
export async function DELETE(request: Request) {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const body = await request.json().catch(() => ({}));
const { ids } = body as { ids?: string[] };
if (ids && Array.isArray(ids)) {
for (const id of ids) {
readNotificationIds.add(id);
}
// Soft-delete specific notifications
await db.notification.updateMany({
where: {
id: { in: ids },
userId: session.user.id,
},
data: { deletedAt: new Date() },
});
} else {
// Soft-delete all notifications for the user
await db.notification.updateMany({
where: {
userId: session.user.id,
deletedAt: null,
},
data: { deletedAt: new Date() },
});
}
return NextResponse.json({ success: true });
@@ -168,18 +308,3 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}
// Helper — minimal i18n for server-side timestamps
function t(key: string, params?: Record<string, string>): string {
const dict: Record<string, string> = {
'notifications.in_days': 'in {days} Tagen',
'notifications.days_ago': 'vor {days} Tagen',
};
let text = dict[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), v);
}
}
return text;
}