feat: add parent announcements and family or child event responses
This commit is contained in:
@@ -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",
|
||||
};
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user