Add 8 files, update 14 files

This commit is contained in:
[dyad] committed 2026-08-02 14:00:21 +02:00
1 parent 669e029f45
commit c84b080fb8
22 files changed
+732 -101

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
import { db } from '@/lib/db';
import type { SessionUser } from '@/lib/auth';
import { isAdministrator } from '@/lib/role-access';
export async function getTeacherClassIds(userId: string): Promise<string[]> {
const classes = await db.classGroup.findMany({
where: {
OR: [
{ responsibleTeacherId: userId },
{ teachers: { some: { userId } } },
],
},
select: { id: true },
});
return classes.map((classGroup) => classGroup.id);
}
export async function canAccessStudent(user: SessionUser, studentId: string): Promise<boolean> {
if (isAdministrator(user.role)) return true;
const student = await db.student.findFirst({
where: { id: studentId, deletedAt: null },
select: { id: true, userId: true, schoolId: true },
});
if (!student || (user.schoolId && student.schoolId !== user.schoolId)) return false;
if (user.role === 'STUDENT') return student.userId === user.id;
if (user.role === 'PARENT') {
return Boolean(
await db.parentStudentLink.findFirst({
where: { parentId: user.id, studentId, schoolId: student.schoolId },
select: { id: true },
})
);
}
if (user.role === 'TEACHER') {
const classIds = await getTeacherClassIds(user.id);
return Boolean(
classIds.length && await db.enrollment.findFirst({
where: { studentId, classGroupId: { in: classIds }, endDate: null },
select: { id: true },
})
);
}
return false;
}
export async function canAccessClass(user: SessionUser, classGroupId: string): Promise<boolean> {
if (isAdministrator(user.role)) return true;
if (user.role === 'TEACHER') {
const classIds = await getTeacherClassIds(user.id);
return classIds.includes(classGroupId);
}
if (user.role === 'STUDENT') {
return Boolean(await db.enrollment.findFirst({
where: { classGroupId, student: { userId: user.id }, endDate: null },
select: { id: true },
}));
}
if (user.role === 'PARENT') {
return Boolean(await db.enrollment.findFirst({
where: { classGroupId, endDate: null, student: { parentStudentLinks: { some: { parentId: user.id } } } },
select: { id: true },
}));
}
return false;
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, test } from 'bun:test';
import {
addBusinessDays,
canCreateGroupRoom,
canAccessRoom,
canEscalateToAdmin,
canMessageAdministrators,
} from './communication-policy';
describe('communication policy', () => {
test('counts five business days without weekends', () => {
const friday = new Date('2026-07-31T10:00:00Z');
expect(addBusinessDays(friday, 5).toISOString().slice(0, 10)).toBe('2026-08-07');
});
test('only enables escalation after the deadline without a resolution', () => {
const now = new Date('2026-08-10T10:00:00Z');
expect(canEscalateToAdmin({ audienceType: 'direct', resolutionStatus: 'open', escalationEligibleAt: new Date('2026-08-07T10:00:00Z'), escalatedAt: null }, now)).toBeTrue();
expect(canEscalateToAdmin({ audienceType: 'direct', resolutionStatus: 'resolved', escalationEligibleAt: new Date('2026-08-07T10:00:00Z'), escalatedAt: null }, now)).toBeFalse();
});
test('limits groups and direct administrator messaging to staff', () => {
expect(canCreateGroupRoom('TEACHER')).toBeTrue();
expect(canCreateGroupRoom('PARENT')).toBeFalse();
expect(canMessageAdministrators('TEACHER')).toBeTrue();
expect(canMessageAdministrators('STUDENT')).toBeFalse();
});
test('only exposes escalated student rooms to school administrators', () => {
const room = { schoolId: 'school-1', studentId: 'student-1', teacherId: 'teacher-1', escalatedAt: null };
expect(canAccessRoom('SCHOOL_ADMIN', room, 'admin-1', 'school-1')).toBeFalse();
expect(canAccessRoom('TEACHER', room, 'teacher-1', 'school-1')).toBeTrue();
expect(canAccessRoom('STUDENT', room, 'student-1', 'school-1')).toBeTrue();
});
});
+56
View File
@@ -0,0 +1,56 @@
import type { AppRole } from '@/lib/role-access';
const ADMIN_ROLES: readonly AppRole[] = ['SCHOOL_ADMIN', 'SUPER_ADMIN'];
export function addBusinessDays(from: Date, days: number): Date {
const result = new Date(from);
let remaining = days;
while (remaining > 0) {
result.setDate(result.getDate() + 1);
const day = result.getDay();
if (day !== 0 && day !== 6) remaining -= 1;
}
return result;
}
export function escalationEligibleAt(createdAt: Date): Date {
return addBusinessDays(createdAt, 5);
}
export function canEscalateToAdmin(room: {
audienceType: string;
resolutionStatus: string;
escalationEligibleAt: Date | null;
escalatedAt: Date | null;
}, now = new Date()): boolean {
return room.audienceType === 'direct'
&& room.resolutionStatus !== 'resolved'
&& !room.escalatedAt
&& Boolean(room.escalationEligibleAt && room.escalationEligibleAt <= now);
}
export function canCreateDirectRoom(role: string | undefined): boolean {
return role === 'STUDENT' || role === 'PARENT' || role === 'TEACHER';
}
export function canCreateGroupRoom(role: string | undefined): boolean {
return role === 'TEACHER' || ADMIN_ROLES.includes(role as AppRole);
}
export function canMessageAdministrators(role: string | undefined): boolean {
return role === 'TEACHER' || ADMIN_ROLES.includes(role as AppRole);
}
export function canAccessRoom(
role: string | undefined,
room: { schoolId: string; studentId: string; teacherId: string; escalatedAt: Date | null },
userId: string,
userSchoolId: string | null
): boolean {
if (role === 'SUPER_ADMIN') return true;
if (role === 'SCHOOL_ADMIN') return room.schoolId === userSchoolId && Boolean(room.escalatedAt);
if (role === 'VICE_PRINCIPAL') return room.schoolId === userSchoolId && room.teacherId === userId;
return room.studentId === userId || room.teacherId === userId;
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, test } from 'bun:test';
import { canAdminApprove, canCreateCalendarEvent, getLeaveState } from './leave-policy';
describe('leave policy', () => {
test('requires parent then administrator approval', () => {
expect(getLeaveState({ parentApprovalStatus: 'pending', adminApprovalStatus: 'pending' })).toBe('awaiting_parent_approval');
expect(getLeaveState({ parentApprovalStatus: 'approved', adminApprovalStatus: 'pending' })).toBe('awaiting_admin_approval');
expect(getLeaveState({ parentApprovalStatus: 'approved', adminApprovalStatus: 'approved' })).toBe('approved');
});
test('only administrators can give the final approval', () => {
expect(canAdminApprove('SCHOOL_ADMIN')).toBeTrue();
expect(canAdminApprove('VICE_PRINCIPAL')).toBeFalse();
});
test('creates one calendar event after full approval', () => {
expect(canCreateCalendarEvent({ parentApprovalStatus: 'approved', adminApprovalStatus: 'approved', calendarEventId: null })).toBeTrue();
expect(canCreateCalendarEvent({ parentApprovalStatus: 'approved', adminApprovalStatus: 'pending', calendarEventId: null })).toBeFalse();
});
});
+35
View File
@@ -0,0 +1,35 @@
export type LeaveState =
| 'awaiting_parent_approval'
| 'awaiting_admin_approval'
| 'approved'
| 'rejected';
export function getLeaveState(report: {
parentApprovalStatus: string;
adminApprovalStatus: string;
}): LeaveState {
if (report.parentApprovalStatus === 'rejected' || report.adminApprovalStatus === 'rejected') {
return 'rejected';
}
if (report.parentApprovalStatus !== 'approved') return 'awaiting_parent_approval';
if (report.adminApprovalStatus !== 'approved') return 'awaiting_admin_approval';
return 'approved';
}
export function canParentApprove(role: string | undefined): boolean {
return role === 'PARENT';
}
export function canAdminApprove(role: string | undefined): boolean {
return role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
}
export function canCreateCalendarEvent(report: {
parentApprovalStatus: string;
adminApprovalStatus: string;
calendarEventId: string | null;
}): boolean {
return report.parentApprovalStatus === 'approved'
&& report.adminApprovalStatus === 'approved'
&& !report.calendarEventId;
}
+29 -1
View File
@@ -1,6 +1,13 @@
import { describe, expect, test } from 'bun:test';
import type { ViewName } from './store';
import { canAccessView, filterSectionsForRole, filterViewsForRole, getFallbackView } from './role-access';
import {
canAccessView,
canManageStudent,
canManageUser,
filterSectionsForRole,
filterViewsForRole,
getFallbackView,
} from './role-access';
const mixedItems = [
{ key: 'dashboard' as const },
@@ -35,4 +42,25 @@ describe('role access', () => {
expect(canAccessView('SUPER_ADMIN', view)).toBeTrue();
}
});
test('limits student and parent menus to self-service views', () => {
expect(canAccessView('STUDENT', 'classes')).toBeFalse();
expect(canAccessView('STUDENT', 'student-portal')).toBeTrue();
expect(canAccessView('PARENT', 'parents')).toBeFalse();
expect(canAccessView('PARENT', 'parent-portal')).toBeTrue();
});
test('does not grant vice principals administrator account control', () => {
expect(canManageUser('VICE_PRINCIPAL', 'TEACHER')).toBeFalse();
expect(canManageUser('SCHOOL_ADMIN', 'SCHOOL_ADMIN', true)).toBeFalse();
expect(canManageUser('SCHOOL_ADMIN', 'SUPER_ADMIN')).toBeFalse();
expect(canManageUser('SUPER_ADMIN', 'SCHOOL_ADMIN')).toBeTrue();
});
test('allows student record mutation only to administrators and teachers', () => {
expect(canManageStudent('SCHOOL_ADMIN')).toBeTrue();
expect(canManageStudent('TEACHER')).toBeTrue();
expect(canManageStudent('VICE_PRINCIPAL')).toBeFalse();
expect(canManageStudent('PARENT')).toBeFalse();
});
});
+27 -15
View File
@@ -2,29 +2,26 @@ import type { ViewName } from '@/lib/store';
export type AppRole = 'PARENT' | 'STUDENT' | 'TEACHER' | 'VICE_PRINCIPAL' | 'SCHOOL_ADMIN' | 'SUPER_ADMIN' | 'DPO';
export const ADMIN_ROLES: readonly AppRole[] = ['SCHOOL_ADMIN', 'SUPER_ADMIN'];
export const USER_MANAGER_ROLES: readonly AppRole[] = ['SCHOOL_ADMIN', 'SUPER_ADMIN'];
export const STUDENT_MANAGER_ROLES: readonly AppRole[] = ['SCHOOL_ADMIN', 'SUPER_ADMIN', 'TEACHER'];
const parentViews: ViewName[] = [
'dashboard', 'parent-portal', 'parents', 'grading', 'attendance', 'calendar',
'communication', 'illness', 'report-cards', 'school-events', 'school-newsletter',
'notification-center', 'announcements', 'settings',
'dashboard', 'parent-portal', 'calendar', 'communication', 'illness',
'notification-center', 'announcements',
];
const studentViews: ViewName[] = [
'dashboard', 'student-portal', 'notebooks', 'flower', 'grading', 'grade-analytics',
'homework', 'attendance', 'portfolio', 'calendar', 'competitions', 'subjects',
'illness', 'communication', 'counseling', 'ai-tests', 'exam-calendar',
'peer-assessment', 'report-cards', 'student-achievements', 'student-study-planner',
'student-wellness', 'student-career', 'resources', 'school-library', 'school-events',
'school-newsletter', 'school-transport', 'notification-center', 'announcements', 'settings',
'dashboard', 'student-portal', 'calendar', 'communication', 'illness',
'notification-center',
];
const teacherViews: ViewName[] = [
'dashboard', 'classes', 'competencies', 'progress', 'flower', 'assessments', 'grading',
'reports', 'settings', 'student-detail', 'matrix', 'attendance', 'calendar', 'lesson-plans',
'parents', 'behavior', 'coverage', 'rubrics', 'comments', 'notebooks', 'drawing', 'homework',
'portfolio', 'timetable', 'resources', 'competitions', 'subjects', 'illness', 'communication',
'counseling', 'ai-tests', 'notification-center', 'announcements', 'tablet-grading',
'exam-calendar', 'peer-assessment', 'seating-chart', 'school-library', 'report-cards',
'student-wellness', 'student-career', 'school-events', 'school-newsletter',
'reports', 'student-detail', 'matrix', 'attendance', 'calendar', 'lesson-plans',
'behavior', 'coverage', 'rubrics', 'comments', 'homework', 'timetable', 'resources',
'illness', 'communication', 'notification-center', 'tablet-grading', 'exam-calendar',
'seating-chart', 'report-cards',
];
const vicePrincipalViews: ViewName[] = [
@@ -36,6 +33,21 @@ export function hasRoleAccess(role: string | undefined, allowedRoles: readonly A
return role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || (role !== undefined && allowedRoles.includes(role as AppRole));
}
export function isAdministrator(role: string | undefined): boolean {
return role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
}
export function canManageUser(actorRole: string | undefined, targetRole: string, isSelf = false): boolean {
if (!isAdministrator(actorRole)) return false;
if (isSelf) return false;
if (actorRole === 'SCHOOL_ADMIN' && targetRole === 'SUPER_ADMIN') return false;
return true;
}
export function canManageStudent(actorRole: string | undefined): boolean {
return STUDENT_MANAGER_ROLES.includes(actorRole as AppRole);
}
export function canAccessView(role: string | undefined, view: ViewName): boolean {
if (role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN') return true;
const allowed = role === 'PARENT' ? parentViews