Add superadmin audit and Kita management
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user