Initial commit: ExaMetra v1.0.0 — Free & open-source competence & grading platform
Features: - 15+ views: Dashboard, Classes, Progress, Flower, Matrix, Assessments, Grading, Reports, Attendance, Calendar, Lesson Plans, Analytics, Parent Communication, Behavior Tracking, Settings - 34+ API endpoints - 30+ Prisma models - 700+ i18n keys (German + English) - Keyboard shortcuts (Cmd+K, Cmd+1-9, Cmd+/) - Batch deletion, drag-and-drop seating, custom color palette - Demo accounts (admin, teacher, student, parent) - Dark mode, responsive design, print styles - CSV/PDF export, notification system, onboarding tour - Zero emojis — all replaced with Lucide icons - MIT License
This commit is contained in:
commit
0c1f739989
156 files changed
+51090
No files matched your search
@@ -0,0 +1,158 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const noteCategoryEnum = z.enum([
|
||||
'GENERAL',
|
||||
'BEHAVIOR',
|
||||
'ACADEMIC',
|
||||
'INTERVENTION',
|
||||
'PARENT_CONTACT',
|
||||
]);
|
||||
|
||||
const updateNoteSchema = z.object({
|
||||
category: noteCategoryEnum.optional(),
|
||||
content: z.string().min(1, 'Content cannot be empty').optional(),
|
||||
isPrivate: z.boolean().optional(),
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = updateNoteSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await db.teacherNote.findUnique({
|
||||
where: { id },
|
||||
include: { student: { select: { schoolId: true, firstName: true, lastName: true } } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only the author (or an admin of the school) can edit
|
||||
const isAuthor = existing.teacherId === session.userId;
|
||||
const isSchoolAdmin =
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.student.schoolId === session.user.schoolId;
|
||||
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
|
||||
|
||||
if (!isAuthor && !isSchoolAdmin && !isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (parsed.data.category !== undefined) data.category = parsed.data.category;
|
||||
if (parsed.data.content !== undefined) data.content = parsed.data.content.trim();
|
||||
if (parsed.data.isPrivate !== undefined) data.isPrivate = parsed.data.isPrivate;
|
||||
|
||||
const note = await db.teacherNote.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.student.schoolId,
|
||||
action: 'UPDATE',
|
||||
entityType: 'TeacherNote',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({
|
||||
studentId: existing.studentId,
|
||||
studentName: `${existing.student.firstName} ${existing.student.lastName}`,
|
||||
changes: parsed.data,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(note);
|
||||
} catch (error) {
|
||||
console.error('TeacherNote PUT 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 });
|
||||
}
|
||||
|
||||
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await db.teacherNote.findUnique({
|
||||
where: { id },
|
||||
include: { student: { select: { schoolId: true, firstName: true, lastName: true } } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isAuthor = existing.teacherId === session.userId;
|
||||
const isSchoolAdmin =
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.student.schoolId === session.user.schoolId;
|
||||
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
|
||||
|
||||
if (!isAuthor && !isSchoolAdmin && !isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
await db.teacherNote.delete({ where: { id } });
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.student.schoolId,
|
||||
action: 'DELETE',
|
||||
entityType: 'TeacherNote',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({
|
||||
studentId: existing.studentId,
|
||||
studentName: `${existing.student.firstName} ${existing.student.lastName}`,
|
||||
category: existing.category,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('TeacherNote DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const noteCategoryEnum = z.enum([
|
||||
'GENERAL',
|
||||
'BEHAVIOR',
|
||||
'ACADEMIC',
|
||||
'INTERVENTION',
|
||||
'PARENT_CONTACT',
|
||||
]);
|
||||
|
||||
const createNoteSchema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
category: noteCategoryEnum,
|
||||
content: z.string().min(1, 'Content cannot be empty'),
|
||||
isPrivate: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only teachers + admins may view private notes
|
||||
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const studentId = searchParams.get('studentId');
|
||||
|
||||
if (!studentId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'studentId is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the student belongs to a school the user can see
|
||||
const student = await db.student.findUnique({
|
||||
where: { id: studentId, deletedAt: null },
|
||||
select: { id: true, schoolId: true },
|
||||
});
|
||||
|
||||
if (!student) {
|
||||
return NextResponse.json({ error: 'Student not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
student.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const notes = await db.teacherNote.findMany({
|
||||
where: { studentId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(notes);
|
||||
} catch (error) {
|
||||
console.error('TeacherNotes 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 });
|
||||
}
|
||||
|
||||
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createNoteSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { studentId, category, content, isPrivate = true } = parsed.data;
|
||||
|
||||
// Verify student exists and is in scope
|
||||
const student = await db.student.findUnique({
|
||||
where: { id: studentId, deletedAt: null },
|
||||
select: { id: true, schoolId: true, firstName: true, lastName: true },
|
||||
});
|
||||
|
||||
if (!student) {
|
||||
return NextResponse.json({ error: 'Student not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
student.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const note = await db.teacherNote.create({
|
||||
data: {
|
||||
studentId,
|
||||
teacherId: session.userId,
|
||||
category,
|
||||
content: content.trim(),
|
||||
isPrivate,
|
||||
},
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: student.schoolId,
|
||||
action: 'CREATE',
|
||||
entityType: 'TeacherNote',
|
||||
entityId: note.id,
|
||||
metadata: JSON.stringify({
|
||||
studentId,
|
||||
category,
|
||||
studentName: `${student.firstName} ${student.lastName}`,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(note, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('TeacherNotes POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user