feat: add parent announcements and family or child event responses

This commit is contained in:
t.indorf
2026-10-09 21:44:19 +02:00
parent 2d3b82eb80
commit 979f795341
29 changed files with 2031 additions and 472 deletions
+38
View File
@@ -0,0 +1,38 @@
import { AnnouncementAudience, UserRole } from "@prisma/client";
// Admins need access to all publications for management. Other roles only
// receive their audience; a parent with a coordination role is still a parent.
export function visibleAnnouncementAudiences(user: {
role: UserRole;
familyId?: string | null;
}): AnnouncementAudience[] {
if (user.role === UserRole.ADMIN) return Object.values(AnnouncementAudience);
const audiences: AnnouncementAudience[] = [AnnouncementAudience.ALLE];
if (user.role === UserRole.ERZIEHER)
audiences.push(AnnouncementAudience.ERZIEHER);
if (
user.role !== UserRole.ERZIEHER &&
(user.familyId || user.role === UserRole.ELTERN)
) {
audiences.push(AnnouncementAudience.ELTERN);
}
return audiences;
}
export function announcementRecipientWhere(audience: AnnouncementAudience) {
const parents = {
role: { not: UserRole.ERZIEHER },
OR: [{ familyId: { not: null } }, { role: UserRole.ELTERN }],
};
if (audience === AnnouncementAudience.ELTERN) return parents;
if (audience === AnnouncementAudience.ERZIEHER)
return { role: UserRole.ERZIEHER };
return { OR: [parents, { role: UserRole.ERZIEHER }] };
}
export const announcementAudienceLabels: Record<AnnouncementAudience, string> =
{
ELTERN: "Eltern",
ERZIEHER: "ErzieherInnen",
ALLE: "Eltern und ErzieherInnen",
};
+48
View File
@@ -0,0 +1,48 @@
import "server-only";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import type { KitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { visibleAnnouncementAudiences } from "@/lib/announcement-access";
export async function getAnnouncements(
session: KitaSession,
options: { take: number; skip?: number },
) {
const rows = await prisma.announcement.findMany({
where: {
kitaId: session.user.kitaId,
audience: { in: visibleAnnouncementAudiences(session.user) },
},
select: {
id: true,
title: true,
content: true,
createdAt: true,
author: { select: { firstName: true, lastName: true } },
reads: { where: { userId: session.user.id }, select: { id: true } },
attachments: {
select: { id: true, fileName: true, fileUrl: true, fileType: true },
},
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: options.take,
skip: options.skip,
});
return rows.map((row) => ({
id: row.id,
title: row.title,
content: row.content,
excerpt: row.content
.replace(/^#{1,6}\s+/gm, "")
.replace(/!?(\[([^\]]+)\])\([^)]+\)/g, "$2")
.replace(/(\*\*|__|`)/g, "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 300),
createdAt: format(row.createdAt, "dd.MM.yyyy HH:mm", { locale: de }),
authorName: `${row.author.firstName} ${row.author.lastName}`,
isUnread: row.reads.length === 0,
attachments: row.attachments,
}));
}
+10
View File
@@ -0,0 +1,10 @@
import type { Prisma } from "@prisma/client";
// Serialize RSVP writes and changes of the response mode on the same event.
export async function lockEvent(
tx: Prisma.TransactionClient,
eventId: string,
kitaId: string,
) {
await tx.$queryRaw`SELECT id FROM termine WHERE id = ${eventId} AND "kitaId" = ${kitaId} FOR UPDATE`;
}
+77
View File
@@ -0,0 +1,77 @@
import "server-only";
import { RsvpMode, TerminStatus, UserRole } from "@prisma/client";
import type { KitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { hasMissingRsvp, type RsvpEventDto } from "@/lib/rsvp";
export async function getRsvpEvents(
session: KitaSession,
options: { onlyOpen?: boolean } = {},
): Promise<RsvpEventDto[]> {
if (!session.user.familyId || session.user.role === UserRole.ERZIEHER)
return [];
const now = new Date();
const familyId = session.user.familyId;
const [events, children] = await Promise.all([
prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
requiresRsvp: true,
status: TerminStatus.CONFIRMED,
kita: { terminModuleEnabled: true },
...(options.onlyOpen
? {
startDate: { gt: now },
OR: [{ rsvpDeadline: null }, { rsvpDeadline: { gt: now } }],
}
: {}),
},
select: {
id: true,
title: true,
startDate: true,
rsvpDeadline: true,
rsvpMode: true,
participations: {
where: { child: { familyId, active: true } },
select: { childId: true, status: true },
},
familyParticipations: {
where: { familyId },
select: { status: true, adultCount: true },
},
},
orderBy: { startDate: "asc" },
}),
prisma.child.findMany({
where: { kitaId: session.user.kitaId, familyId, active: true },
select: { id: true, firstName: true, lastName: true },
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
}),
]);
const rows: RsvpEventDto[] = events
.filter(
(event) => event.rsvpMode === RsvpMode.FAMILY || children.length > 0,
)
.map((event) => ({
id: event.id,
title: event.title,
startDate: event.startDate.toISOString(),
rsvpDeadline: event.rsvpDeadline?.toISOString() ?? null,
mode: event.rsvpMode,
canReply:
(event.rsvpDeadline ?? event.startDate) > now && event.startDate > now,
familyResponse: event.familyParticipations[0] ?? null,
children:
event.rsvpMode === RsvpMode.CHILD
? children.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
status:
event.participations.find((row) => row.childId === child.id)
?.status ?? null,
}))
: [],
}));
return options.onlyOpen ? rows.filter(hasMissingRsvp) : rows;
}
+70
View File
@@ -0,0 +1,70 @@
import { EventParticipationStatus, RsvpMode, TerminType } from "@prisma/client";
import { z } from "zod";
export const rsvpSubmissionSchema = z.discriminatedUnion("mode", [
z.object({
eventId: z.string().min(1),
mode: z.literal(RsvpMode.CHILD),
responses: z
.array(
z.object({
childId: z.string().min(1),
status: z.nativeEnum(EventParticipationStatus),
}),
)
.min(1)
.refine(
(rows) => new Set(rows.map((row) => row.childId)).size === rows.length,
"Doppelte Kinderantwort",
),
}),
z
.object({
eventId: z.string().min(1),
mode: z.literal(RsvpMode.FAMILY),
status: z.nativeEnum(EventParticipationStatus),
adultCount: z.number().int().min(0).max(2147483647),
})
.refine(
(row) =>
row.status === EventParticipationStatus.ATTENDING
? row.adultCount >= 1
: row.adultCount === 0,
"Bei Zusage bitte die Anzahl Erwachsener angeben; bei Absage muss sie null sein.",
),
]);
export function suggestedRsvpMode(type: TerminType): RsvpMode {
return new Set<TerminType>([
TerminType.ELTERNABEND,
TerminType.MITGLIEDERVERSAMMLUNG,
TerminType.ELTERNCAFE,
TerminType.KITA_FEST,
]).has(type)
? RsvpMode.FAMILY
: RsvpMode.CHILD;
}
export type RsvpEventDto = {
id: string;
title: string;
startDate: string;
rsvpDeadline: string | null;
mode: RsvpMode;
canReply: boolean;
familyResponse: {
status: EventParticipationStatus;
adultCount: number;
} | null;
children: {
id: string;
name: string;
status: EventParticipationStatus | null;
}[];
};
export function hasMissingRsvp(event: RsvpEventDto) {
return event.mode === RsvpMode.FAMILY
? !event.familyResponse
: event.children.some((child) => !child.status);
}