439 lines
10 KiB
TypeScript
439 lines
10 KiB
TypeScript
import "server-only";
|
|
|
|
import { AuditTargetType, InvitationStatus, UserRole } from "@prisma/client";
|
|
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
const roleOrder: UserRole[] = [
|
|
UserRole.ADMIN,
|
|
UserRole.KOORDINATOR,
|
|
UserRole.ERZIEHER,
|
|
UserRole.ELTERN,
|
|
];
|
|
|
|
export type KitaOverviewRow = {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
modules: {
|
|
notdienst: boolean;
|
|
termine: boolean;
|
|
adressbuch: boolean;
|
|
};
|
|
counts: {
|
|
users: number;
|
|
families: number;
|
|
activeChildren: number;
|
|
termine: number;
|
|
absences: number;
|
|
announcements: number;
|
|
openInvitations: number;
|
|
lockedUsers: number;
|
|
missingConsent: number;
|
|
familiesWithoutUsers: number;
|
|
};
|
|
lastLoginAt: Date | null;
|
|
roleCounts: Record<UserRole, number>;
|
|
};
|
|
|
|
export type GlobalAdminHealth = {
|
|
orphanTenantUsers: number;
|
|
lockedTenantUsers: number;
|
|
openInvitations: number;
|
|
missingConsent: number;
|
|
kitas: number;
|
|
};
|
|
|
|
export async function getGlobalAdminHealth(): Promise<GlobalAdminHealth> {
|
|
const now = new Date();
|
|
const [
|
|
orphanTenantUsers,
|
|
lockedTenantUsers,
|
|
openInvitations,
|
|
missingConsent,
|
|
kitas,
|
|
] = await Promise.all([
|
|
prisma.user.count({
|
|
where: {
|
|
kitaId: null,
|
|
role: { not: UserRole.SUPERADMIN },
|
|
},
|
|
}),
|
|
prisma.user.count({
|
|
where: {
|
|
role: { not: UserRole.SUPERADMIN },
|
|
lockedUntil: { gt: now },
|
|
},
|
|
}),
|
|
prisma.invitation.count({
|
|
where: {
|
|
status: InvitationStatus.PENDING,
|
|
expiresAt: { gt: now },
|
|
},
|
|
}),
|
|
prisma.user.count({
|
|
where: {
|
|
role: { not: UserRole.SUPERADMIN },
|
|
privacyPolicyAcceptedAt: null,
|
|
},
|
|
}),
|
|
prisma.kita.count(),
|
|
]);
|
|
|
|
return {
|
|
orphanTenantUsers,
|
|
lockedTenantUsers,
|
|
openInvitations,
|
|
missingConsent,
|
|
kitas,
|
|
};
|
|
}
|
|
|
|
export async function getKitaOverviewRows(): Promise<KitaOverviewRow[]> {
|
|
const now = new Date();
|
|
const kitas = await prisma.kita.findMany({
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
slug: true,
|
|
notdienstModuleEnabled: true,
|
|
terminModuleEnabled: true,
|
|
adressbuchModuleEnabled: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
users: {
|
|
select: {
|
|
role: true,
|
|
lastLoginAt: true,
|
|
lockedUntil: true,
|
|
privacyPolicyAcceptedAt: true,
|
|
},
|
|
},
|
|
families: {
|
|
select: {
|
|
users: {
|
|
select: { id: true },
|
|
},
|
|
},
|
|
},
|
|
children: {
|
|
select: { active: true },
|
|
},
|
|
invitations: {
|
|
select: {
|
|
status: true,
|
|
expiresAt: true,
|
|
},
|
|
},
|
|
_count: {
|
|
select: {
|
|
termine: true,
|
|
absences: true,
|
|
announcements: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: { name: "asc" },
|
|
});
|
|
|
|
return kitas.map((kita) => {
|
|
const roleCounts = Object.fromEntries(
|
|
Object.values(UserRole).map((role) => [role, 0]),
|
|
) as Record<UserRole, number>;
|
|
|
|
let lastLoginAt: Date | null = null;
|
|
let lockedUsers = 0;
|
|
let missingConsent = 0;
|
|
|
|
for (const user of kita.users) {
|
|
roleCounts[user.role] += 1;
|
|
if (user.lastLoginAt && (!lastLoginAt || user.lastLoginAt > lastLoginAt)) {
|
|
lastLoginAt = user.lastLoginAt;
|
|
}
|
|
if (user.lockedUntil && user.lockedUntil > now) {
|
|
lockedUsers += 1;
|
|
}
|
|
if (user.role !== UserRole.SUPERADMIN && !user.privacyPolicyAcceptedAt) {
|
|
missingConsent += 1;
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: kita.id,
|
|
name: kita.name,
|
|
slug: kita.slug,
|
|
createdAt: kita.createdAt,
|
|
updatedAt: kita.updatedAt,
|
|
modules: {
|
|
notdienst: kita.notdienstModuleEnabled,
|
|
termine: kita.terminModuleEnabled,
|
|
adressbuch: kita.adressbuchModuleEnabled,
|
|
},
|
|
counts: {
|
|
users: kita.users.length,
|
|
families: kita.families.length,
|
|
activeChildren: kita.children.filter((child) => child.active).length,
|
|
termine: kita._count.termine,
|
|
absences: kita._count.absences,
|
|
announcements: kita._count.announcements,
|
|
openInvitations: kita.invitations.filter(
|
|
(invitation) =>
|
|
invitation.status === InvitationStatus.PENDING &&
|
|
invitation.expiresAt > now,
|
|
).length,
|
|
lockedUsers,
|
|
missingConsent,
|
|
familiesWithoutUsers: kita.families.filter(
|
|
(family) => family.users.length === 0,
|
|
).length,
|
|
},
|
|
lastLoginAt,
|
|
roleCounts,
|
|
};
|
|
});
|
|
}
|
|
|
|
export type KitaDiagnostics = {
|
|
kita: {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
modules: KitaOverviewRow["modules"];
|
|
};
|
|
counts: KitaOverviewRow["counts"] & {
|
|
inactiveChildren: number;
|
|
expiredPendingInvitations: number;
|
|
};
|
|
roleCounts: Record<UserRole, number>;
|
|
users: {
|
|
id: string;
|
|
email: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
role: UserRole;
|
|
familyName: string | null;
|
|
emailVerifiedAt: Date | null;
|
|
privacyPolicyAcceptedAt: Date | null;
|
|
lastLoginAt: Date | null;
|
|
failedLoginAttempts: number;
|
|
lockedUntil: Date | null;
|
|
createdAt: Date;
|
|
}[];
|
|
diagnostics: {
|
|
lockedUsers: number;
|
|
missingConsent: number;
|
|
unverifiedUsers: number;
|
|
familiesWithoutUsers: number;
|
|
usersWithEmptyPassword: number;
|
|
};
|
|
};
|
|
|
|
export async function getKitaDiagnostics(
|
|
kitaId: string,
|
|
): Promise<KitaDiagnostics | null> {
|
|
const now = new Date();
|
|
const kita = await prisma.kita.findUnique({
|
|
where: { id: kitaId },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
slug: true,
|
|
notdienstModuleEnabled: true,
|
|
terminModuleEnabled: true,
|
|
adressbuchModuleEnabled: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
users: {
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
role: true,
|
|
passwordHash: true,
|
|
emailVerifiedAt: true,
|
|
privacyPolicyAcceptedAt: true,
|
|
lastLoginAt: true,
|
|
failedLoginAttempts: true,
|
|
lockedUntil: true,
|
|
createdAt: true,
|
|
family: {
|
|
select: { name: true },
|
|
},
|
|
},
|
|
orderBy: [
|
|
{ role: "asc" },
|
|
{ lastName: "asc" },
|
|
{ firstName: "asc" },
|
|
],
|
|
},
|
|
families: {
|
|
select: {
|
|
users: { select: { id: true } },
|
|
},
|
|
},
|
|
children: {
|
|
select: { active: true },
|
|
},
|
|
invitations: {
|
|
select: {
|
|
status: true,
|
|
expiresAt: true,
|
|
},
|
|
},
|
|
_count: {
|
|
select: {
|
|
termine: true,
|
|
absences: true,
|
|
announcements: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!kita) {
|
|
return null;
|
|
}
|
|
|
|
const roleCounts = Object.fromEntries(
|
|
Object.values(UserRole).map((role) => [role, 0]),
|
|
) as Record<UserRole, number>;
|
|
|
|
let lockedUsers = 0;
|
|
let missingConsent = 0;
|
|
let unverifiedUsers = 0;
|
|
let usersWithEmptyPassword = 0;
|
|
|
|
for (const user of kita.users) {
|
|
roleCounts[user.role] += 1;
|
|
if (user.lockedUntil && user.lockedUntil > now) {
|
|
lockedUsers += 1;
|
|
}
|
|
if (user.role !== UserRole.SUPERADMIN && !user.privacyPolicyAcceptedAt) {
|
|
missingConsent += 1;
|
|
}
|
|
if (!user.emailVerifiedAt) {
|
|
unverifiedUsers += 1;
|
|
}
|
|
if (!user.passwordHash) {
|
|
usersWithEmptyPassword += 1;
|
|
}
|
|
}
|
|
|
|
const activeChildren = kita.children.filter((child) => child.active).length;
|
|
const inactiveChildren = kita.children.length - activeChildren;
|
|
const familiesWithoutUsers = kita.families.filter(
|
|
(family) => family.users.length === 0,
|
|
).length;
|
|
|
|
return {
|
|
kita: {
|
|
id: kita.id,
|
|
name: kita.name,
|
|
slug: kita.slug,
|
|
createdAt: kita.createdAt,
|
|
updatedAt: kita.updatedAt,
|
|
modules: {
|
|
notdienst: kita.notdienstModuleEnabled,
|
|
termine: kita.terminModuleEnabled,
|
|
adressbuch: kita.adressbuchModuleEnabled,
|
|
},
|
|
},
|
|
counts: {
|
|
users: kita.users.length,
|
|
families: kita.families.length,
|
|
activeChildren,
|
|
inactiveChildren,
|
|
termine: kita._count.termine,
|
|
absences: kita._count.absences,
|
|
announcements: kita._count.announcements,
|
|
openInvitations: kita.invitations.filter(
|
|
(invitation) =>
|
|
invitation.status === InvitationStatus.PENDING &&
|
|
invitation.expiresAt > now,
|
|
).length,
|
|
expiredPendingInvitations: kita.invitations.filter(
|
|
(invitation) =>
|
|
invitation.status === InvitationStatus.PENDING &&
|
|
invitation.expiresAt <= now,
|
|
).length,
|
|
lockedUsers,
|
|
missingConsent,
|
|
familiesWithoutUsers,
|
|
},
|
|
roleCounts,
|
|
users: kita.users.map((user) => ({
|
|
id: user.id,
|
|
email: user.email,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
role: user.role,
|
|
familyName: user.family?.name ?? null,
|
|
emailVerifiedAt: user.emailVerifiedAt,
|
|
privacyPolicyAcceptedAt: user.privacyPolicyAcceptedAt,
|
|
lastLoginAt: user.lastLoginAt,
|
|
failedLoginAttempts: user.failedLoginAttempts,
|
|
lockedUntil: user.lockedUntil,
|
|
createdAt: user.createdAt,
|
|
})),
|
|
diagnostics: {
|
|
lockedUsers,
|
|
missingConsent,
|
|
unverifiedUsers,
|
|
familiesWithoutUsers,
|
|
usersWithEmptyPassword,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function recordKitaDiagnosticView({
|
|
actorUserId,
|
|
actorEmail,
|
|
kitaId,
|
|
kitaName,
|
|
kitaSlug,
|
|
}: {
|
|
actorUserId: string;
|
|
actorEmail: string;
|
|
kitaId: string;
|
|
kitaName: string;
|
|
kitaSlug: string;
|
|
}) {
|
|
await prisma.auditLog.create({
|
|
data: {
|
|
actorUserId,
|
|
actorEmail,
|
|
action: "KITA_DIAGNOSTIC_VIEW",
|
|
targetType: AuditTargetType.KITA,
|
|
targetId: kitaId,
|
|
targetLabel: `${kitaName} (${kitaSlug})`,
|
|
targetKitaId: kitaId,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function getAuditLogRows() {
|
|
return prisma.auditLog.findMany({
|
|
select: {
|
|
id: true,
|
|
actorEmail: true,
|
|
action: true,
|
|
targetType: true,
|
|
targetId: true,
|
|
targetLabel: true,
|
|
targetKitaId: true,
|
|
metadata: true,
|
|
createdAt: true,
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
take: 200,
|
|
});
|
|
}
|
|
|
|
export { roleOrder };
|
|
|