added logbook

This commit is contained in:
PxlLoewe
2025-05-30 19:28:07 -07:00
parent 7822369126
commit eaedd78202
17 changed files with 372 additions and 128 deletions

View File

@@ -1,4 +1,4 @@
import { ConnectedAircraft, Mission, prisma } from "@repo/db"; import { ConnectedAircraft, getPublicUser, Mission, prisma, User } from "@repo/db";
import { io } from "index"; import { io } from "index";
import { sendNtfyMission } from "modules/ntfy"; import { sendNtfyMission } from "modules/ntfy";
@@ -9,6 +9,7 @@ export const sendAlert = async (
}: { }: {
stationId?: number; stationId?: number;
}, },
user: User,
): Promise<{ ): Promise<{
connectedAircrafts: ConnectedAircraft[]; connectedAircrafts: ConnectedAircraft[];
mission: Mission; mission: Mission;
@@ -44,7 +45,6 @@ export const sendAlert = async (
}); });
for (const aircraft of connectedAircrafts) { for (const aircraft of connectedAircrafts) {
console.log(`Sending mission to: station:${aircraft.stationId}`);
io.to(`station:${aircraft.stationId}`).emit("mission-alert", { io.to(`station:${aircraft.stationId}`).emit("mission-alert", {
...mission, ...mission,
Stations, Stations,
@@ -54,21 +54,15 @@ export const sendAlert = async (
}); });
if (!user) continue; if (!user) continue;
if (user.settingsNtfyRoom) { if (user.settingsNtfyRoom) {
await sendNtfyMission( await sendNtfyMission(mission, Stations, aircraft.Station, user.settingsNtfyRoom);
mission,
Stations,
aircraft.Station,
user.settingsNtfyRoom,
);
} }
const existingMissionOnStationUser = const existingMissionOnStationUser = await prisma.missionOnStationUsers.findFirst({
await prisma.missionOnStationUsers.findFirst({ where: {
where: { missionId: mission.id,
missionId: mission.id, userId: aircraft.userId,
userId: aircraft.userId, stationId: aircraft.stationId,
stationId: aircraft.stationId, },
}, });
});
if (!existingMissionOnStationUser) if (!existingMissionOnStationUser)
await prisma.missionOnStationUsers.create({ await prisma.missionOnStationUsers.create({
data: { data: {
@@ -95,6 +89,17 @@ export const sendAlert = async (
where: { id: Number(id) }, where: { id: Number(id) },
data: { data: {
state: "running", state: "running",
missionLog: {
push: {
type: "alert-log",
auto: false,
timeStamp: new Date().toISOString(),
data: {
stationId: stationId,
user: getPublicUser(user, { ignorePrivacy: true }),
},
} as any,
},
}, },
}); });
return { connectedAircrafts, mission }; return { connectedAircrafts, mission };

View File

@@ -1,5 +1,7 @@
import { import {
getPublicUser,
HpgValidationState, HpgValidationState,
MissionAlertLog,
MissionSdsLog, MissionSdsLog,
MissionStationLog, MissionStationLog,
NotificationPayload, NotificationPayload,
@@ -119,6 +121,11 @@ router.post("/:id/send-alert", async (req, res) => {
vehicleName?: "ambulance" | "police" | "firebrigade"; vehicleName?: "ambulance" | "police" | "firebrigade";
}; };
if (!req.user) {
res.status(401).json({ error: "Unauthorized" });
return;
}
try { try {
if (vehicleName) { if (vehicleName) {
const hpgAircrafts = await prisma.connectedAircraft.findMany({ const hpgAircrafts = await prisma.connectedAircraft.findMany({
@@ -137,6 +144,17 @@ router.post("/:id/send-alert", async (req, res) => {
hpgAmbulanceState: vehicleName === "ambulance" ? "DISPATCHED" : undefined, hpgAmbulanceState: vehicleName === "ambulance" ? "DISPATCHED" : undefined,
hpgFireEngineState: vehicleName === "firebrigade" ? "DISPATCHED" : undefined, hpgFireEngineState: vehicleName === "firebrigade" ? "DISPATCHED" : undefined,
hpgPoliceState: vehicleName === "police" ? "DISPATCHED" : undefined, hpgPoliceState: vehicleName === "police" ? "DISPATCHED" : undefined,
missionLog: {
push: {
type: "alert-log",
auto: false,
timeStamp: new Date().toISOString(),
data: {
vehicle: vehicleName,
user: getPublicUser(req.user as User),
},
} as any,
},
}, },
}); });
hpgAircrafts.forEach((aircraft) => { hpgAircrafts.forEach((aircraft) => {
@@ -156,9 +174,13 @@ router.post("/:id/send-alert", async (req, res) => {
io.to("dispatchers").emit("update-mission", newMission); io.to("dispatchers").emit("update-mission", newMission);
return; return;
} }
const { connectedAircrafts, mission } = await sendAlert(Number(id), { const { connectedAircrafts, mission } = await sendAlert(
stationId, Number(id),
}); {
stationId,
},
req.user,
);
res.status(200).json({ res.status(200).json({
message: `Einsatz gesendet (${connectedAircrafts.length} Nutzer) `, message: `Einsatz gesendet (${connectedAircrafts.length} Nutzer) `,
@@ -231,7 +253,7 @@ router.post("/:id/validate-hpg", async (req, res) => {
return; return;
} }
res.json({ res.json({
message: "HPG validation started", message: "HPG validierung gestartet",
}); });
io.to(`desktop:${activeAircraftinMission}`).emit( io.to(`desktop:${activeAircraftinMission}`).emit(
@@ -265,7 +287,8 @@ router.post("/:id/validate-hpg", async (req, res) => {
message: `HPG Validierung erfolgreich`, message: `HPG Validierung erfolgreich`,
} as NotificationPayload); } as NotificationPayload);
if (config?.alertWhenValid) { if (config?.alertWhenValid) {
sendAlert(Number(id), {}); if (!req.user) return;
sendAlert(Number(id), {}, req.user);
} }
} else { } else {
io.to(`user:${req.user?.id}`).emit("notification", { io.to(`user:${req.user?.id}`).emit("notification", {
@@ -276,25 +299,6 @@ router.post("/:id/validate-hpg", async (req, res) => {
} }
}, },
); );
// TODO: remove this after testing
setTimeout(() => {
io.to(`user:${req.user?.id}`).emit("notification", {
type: "hpg-validation",
status: "success",
message: "HPG_BUSY",
data: {
mission,
},
} as NotificationPayload);
io.to(`user:${req.user?.id}`).emit("notification", {
type: "hpg-validation",
status: "failed",
message: `HPG Validation fehlgeschlagen`,
data: {
mission,
},
} as NotificationPayload);
}, 5000);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.json({ error: (error as Error).message || "Failed to validate HPG" }); res.json({ error: (error as Error).message || "Failed to validate HPG" });

View File

@@ -24,6 +24,8 @@ import {
HpgState, HpgState,
HpgValidationState, HpgValidationState,
Mission, Mission,
MissionAlertLog,
MissionCompletedLog,
MissionLog, MissionLog,
MissionMessageLog, MissionMessageLog,
Prisma, Prisma,
@@ -47,6 +49,7 @@ const Einsatzdetails = ({
mission: Mission; mission: Mission;
hpgNeedsAttention?: boolean; hpgNeedsAttention?: boolean;
}) => { }) => {
const session = useSession();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const deleteMissionMutation = useMutation({ const deleteMissionMutation = useMutation({
mutationKey: ["missions"], mutationKey: ["missions"],
@@ -127,10 +130,21 @@ const Einsatzdetails = ({
<button <button
className="btn btn-xs btn-warning flex items-center gap-2" className="btn btn-xs btn-warning flex items-center gap-2"
onClick={() => { onClick={() => {
if (!session.data) return;
editMissionMutation.mutate({ editMissionMutation.mutate({
id: mission.id, id: mission.id,
mission: { mission: {
state: "finished", state: "finished",
missionLog: {
push: {
type: "completed-log",
auto: false,
timeStamp: new Date().toISOString(),
data: {
user: getPublicUser(session.data?.user, { ignorePrivacy: true }),
},
} as any,
},
}, },
}); });
}} }}
@@ -593,7 +607,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
timeStamp: new Date().toISOString(), timeStamp: new Date().toISOString(),
data: { data: {
message: note, message: note,
user: getPublicUser(session.data?.user), user: getPublicUser(session.data?.user, { ignorePrivacy: true }),
}, },
} as MissionMessageLog, } as MissionMessageLog,
]; ];
@@ -694,7 +708,49 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
<span className="text-base-content">{entry.data.message}</span> <span className="text-base-content">{entry.data.message}</span>
</li> </li>
); );
if (entry.type === "alert-log") {
const alertReceiver = entry.data.station?.bosCallsignShort || entry.data.vehicle;
return (
<li key={index} className="flex items-center gap-2">
<span className="text-base-content">
{new Date(entry.timeStamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
<span
className="font-bold text-base flex items-center gap-0.5"
style={{
color: FMS_STATUS_TEXT_COLORS[6],
}}
>
{entry.data.user.firstname?.[0]?.toUpperCase() ?? "?"}
{entry.data.user.lastname?.[0]?.toUpperCase() ?? "?"}
{alertReceiver && (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
className="size-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"
/>
</svg>
{alertReceiver}
</>
)}
</span>
<span className="text-base-content">Einsatz alarmiert</span>
</li>
);
}
return null; return null;
})} })}
</ul> </ul>

View File

@@ -154,6 +154,8 @@ export const MissionForm = () => {
if (validationRequired) { if (validationRequired) {
await startHpgValidation(newMission.id, { await startHpgValidation(newMission.id, {
alertWhenValid, alertWhenValid,
}).catch((error) => {
toast.error(`Fehler beim Starten der HPG-Validierung: ${error.message}`);
}); });
} }
return newMission; return newMission;
@@ -171,7 +173,9 @@ export const MissionForm = () => {
}); });
} }
if (validationRequired) { if (validationRequired) {
await startHpgValidation(newMission.id, {}); await startHpgValidation(newMission.id, {}).catch((error) => {
toast.error(`Fehler beim Starten der HPG-Validierung: ${error.message}`);
});
} }
return newMission; return newMission;
}; };

View File

@@ -0,0 +1,80 @@
"use client";
import { Mission, MissionAlertLog, MissionLog, Station } from "@repo/db";
import { ColumnDef } from "@tanstack/react-table";
import { PaginatedTable } from "_components/PaginatedTable";
import { ArrowRight, NotebookText } from "lucide-react";
import { useSession } from "next-auth/react";
import Link from "next/link";
export const RecentFlights = () => {
const session = useSession();
return (
<div className="card-body">
<h2 className="card-title justify-between">
<span className="card-title">
<NotebookText className="w-4 h-4" /> Logbook
</span>
<Link className="badge badge-sm badge-info badge-outline" href="/logbook">
Zum vollständigen Logbook <ArrowRight className="w-4 h-4" />
</Link>
</h2>
<PaginatedTable
prismaModel={"missionOnStationUsers"}
filter={{
userId: session.data?.user?.id ?? "",
Mission: {
state: "finished",
},
}}
include={{
Station: true,
User: true,
Mission: true,
}}
columns={
[
{
header: "Station",
accessorKey: "Station.name",
cell: ({ row }) => row.original.Station.bosCallsign,
},
{
header: "Datum",
accessorKey: "createdAt",
cell: ({ row }) => new Date(row.original.Mission.createdAt).toLocaleDateString(),
},
{
header: "Einsatzlänge",
cell: ({ row }) => {
const missionStartLogs = row.original.Mission.missionLog.filter(
(log) => (log as unknown as MissionLog).type === "alert-log",
) as unknown as MissionAlertLog[] | undefined;
// Find the first log with a station ID that matches the current row's station ID or use the first log if none match
const missionStartLog =
missionStartLogs?.find(
(log) => log.data.station?.id === row.original.Station.id,
) || missionStartLogs?.[0];
const missionEndLog = row.original.Mission.missionLog.find(
(log) => (log as unknown as MissionLog).type === "completed-log",
) as unknown as MissionAlertLog | undefined;
if (!missionStartLog) return "Unbekannt";
if (!missionEndLog) return "Unbekannt";
const start = new Date(missionStartLog.timeStamp);
const end = new Date(missionEndLog?.timeStamp);
const duration = Math.round((end.getTime() - start.getTime()) / 1000 / 60); // in minutes
return `${duration} Minuten`;
},
},
] as ColumnDef<{
Station: Station;
Mission: Mission;
}>[]
}
/>
</div>
);
};

View File

@@ -274,10 +274,13 @@ export const DispoStats = async () => {
); );
}; };
export const Stats = ({ stats }: { stats: "pilot" | "dispo" }) => { export const Stats = async ({ stats }: { stats: "pilot" | "dispo" }) => {
const session = await getServerSession();
if (!session) return null;
return ( return (
<> <>
<StatsToggle /> {session.user.permissions.includes("DISPO") && <StatsToggle />}
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3"> <div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
{stats === "dispo" && <DispoStats />} {stats === "dispo" && <DispoStats />}
{stats === "pilot" && <PilotStats />} {stats === "pilot" && <PilotStats />}

View File

@@ -30,30 +30,19 @@ export const StatsToggle = () => {
return ( return (
<header className="flex justify-between items-center p-4"> <header className="flex justify-between items-center p-4">
<h1 className="text-2xl font-bold"> <h1 className="text-2xl font-bold">
Hallo,{" "} Hallo, {session.status === "authenticated" ? session.data?.user.firstname : "<username>"}
{session.status === "authenticated"
? session.data?.user.firstname
: "<username>"}
{"!"} {"!"}
</h1> </h1>
<div> <div>
<div className="tooltip" data-tip="Disponent / Pilot"> <div className="tooltip tooltip-left" data-tip="Disponent / Pilot">
<label className="toggle text-base-content"> <label className="toggle text-base-content">
<input <input
type="checkbox" type="checkbox"
checked={checked} checked={checked}
onChange={(e) => setChecked(e.target.checked)} onChange={(e) => setChecked(e.target.checked)}
/> />
<Workflow <Workflow className="w-4 h-4" viewBox="0 0 24 24" aria-label="enabled" />
className="w-4 h-4" <PlaneIcon className="w-4 h-4" viewBox="0 0 24 24" aria-label="disabled" />
viewBox="0 0 24 24"
aria-label="enabled"
/>
<PlaneIcon
className="w-4 h-4"
viewBox="0 0 24 24"
aria-label="disabled"
/>
</label> </label>
</div> </div>
</div> </div>

View File

@@ -25,7 +25,7 @@ import {
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import { Button } from "../../../../../_components/ui/Button"; import { Button } from "../../../../../_components/ui/Button";
import { Select } from "../../../../../_components/ui/Select"; import { Select } from "../../../../../_components/ui/Select";
import { UserOptionalDefaults, UserOptionalDefaultsSchema, UserSchema } from "@repo/db/zod"; import { UserOptionalDefaults, UserOptionalDefaultsSchema } from "@repo/db/zod";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { PaginatedTable, PaginatedTableRef } from "_components/PaginatedTable"; import { PaginatedTable, PaginatedTableRef } from "_components/PaginatedTable";
import { cn } from "../../../../../../helper/cn"; import { cn } from "../../../../../../helper/cn";
@@ -41,10 +41,7 @@ interface ProfileFormProps {
export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormProps) => { export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormProps) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const form = useForm<UserOptionalDefaults>({ const form = useForm<UserOptionalDefaults>({
defaultValues: { defaultValues: user,
...user,
emailVerified: user.emailVerified ?? undefined,
},
resolver: zodResolver(UserOptionalDefaultsSchema), resolver: zodResolver(UserOptionalDefaultsSchema),
}); });
if (!user) return <Error title="User not found" statusCode={404} />; if (!user) return <Error title="User not found" statusCode={404} />;

View File

@@ -1,37 +1,41 @@
import { User2 } from 'lucide-react'; import { User2 } from "lucide-react";
import { PaginatedTable } from '../../../_components/PaginatedTable'; import { PaginatedTable } from "../../../_components/PaginatedTable";
export default async () => { const AdminUserPage = async () => {
return ( return (
<> <>
<PaginatedTable <PaginatedTable
showEditButton showEditButton
prismaModel="user" prismaModel="user"
searchFields={['publicId', 'firstname', 'lastname', 'email']} searchFields={["publicId", "firstname", "lastname", "email"]}
columns={[ columns={[
{ {
header: 'ID', header: "ID",
accessorKey: 'publicId', accessorKey: "publicId",
}, },
{ {
header: 'Vorname', header: "Vorname",
accessorKey: 'firstname', accessorKey: "firstname",
}, },
{ {
header: 'Nachname', header: "Nachname",
accessorKey: 'lastname', accessorKey: "lastname",
}, },
{ {
header: 'Email', header: "Email",
accessorKey: 'email', accessorKey: "email",
}, },
]} ]}
leftOfSearch={ leftOfSearch={
<p className="text-2xl font-semibold text-left flex items-center gap-2"> <p className="text-2xl font-semibold text-left flex items-center gap-2">
<User2 className="w-5 h-5" /> Benutzer <User2 className="w-5 h-5" /> Benutzer
</p> </p>
} }
/> />
</> </>
); );
}; };
AdminUserPage.displayName = "AdminUserPage";
export default AdminUserPage;

View File

@@ -42,7 +42,7 @@ export default async function RootLayout({
<VerticalNav /> <VerticalNav />
{/* Scrollbarer Content-Bereich */} {/* Scrollbarer Content-Bereich */}
<div className="flex-grow bg-base-100 p-6 rounded-lg shadow-md ml-4 overflow-auto h-full max-w-full w-full"> <div className="flex-grow bg-base-100 px-6 rounded-lg shadow-md ml-4 overflow-auto h-full max-w-full w-full">
{children} {children}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,16 @@
"use client";
import { Mission, MissionAlertLog, MissionLog, Station } from "@repo/db";
import { ColumnDef } from "@tanstack/react-table";
import { Error } from "_components/Error";
import { PaginatedTable } from "_components/PaginatedTable";
import { NotebookText } from "lucide-react"; import { NotebookText } from "lucide-react";
import { useSession } from "next-auth/react";
const Page = () => {
const session = useSession();
if (!session) return <Error title="Nicht eingelogt" statusCode={401} />;
const page = () => {
return ( return (
<div className="grid grid-cols-6 gap-4"> <div className="grid grid-cols-6 gap-4">
<div className="col-span-full"> <div className="col-span-full">
@@ -9,10 +19,72 @@ const page = () => {
</p> </p>
</div> </div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6"> <div className="card bg-base-200 shadow-xl mb-4 col-span-6">
<h2 className="text-2xl text-gray-600 ">W.I.P.</h2> <PaginatedTable
prismaModel={"missionOnStationUsers"}
filter={{
userId: session.data?.user?.id ?? "",
Mission: {
state: "finished",
},
}}
include={{
Station: true,
User: true,
Mission: true,
}}
columns={
[
{
header: "Station",
accessorKey: "Station.name",
cell: ({ row }) => row.original.Station.bosCallsign,
},
{
header: "Stichwort",
accessorKey: "Mission.name",
cell: ({ row }) =>
`${row.original.Mission.missionKeywordCategory} / ${row.original.Mission.missionKeywordName}`,
},
{
header: "Datum",
accessorKey: "createdAt",
cell: ({ row }) => new Date(row.original.Mission.createdAt).toLocaleDateString(),
},
{
header: "Einsatzlänge",
cell: ({ row }) => {
const missionStartLogs = row.original.Mission.missionLog.filter(
(log) => (log as unknown as MissionLog).type === "alert-log",
) as unknown as MissionAlertLog[] | undefined;
// Find the first log with a station ID that matches the current row's station ID or use the first log if none match
const missionStartLog =
missionStartLogs?.find(
(log) => log.data.station?.id === row.original.Station.id,
) || missionStartLogs?.[0];
const missionEndLog = row.original.Mission.missionLog.find(
(log) => (log as unknown as MissionLog).type === "completed-log",
) as unknown as MissionAlertLog | undefined;
if (!missionStartLog) return "Unbekannt";
if (!missionEndLog) return "Unbekannt";
const start = new Date(missionStartLog.timeStamp);
const end = new Date(missionEndLog?.timeStamp);
const duration = Math.round((end.getTime() - start.getTime()) / 1000 / 60); // in minutes
return `${duration} Minuten`;
},
},
] as ColumnDef<{
Station: Station;
Mission: Mission;
}>[]
}
/>
</div> </div>
</div> </div>
); );
}; };
export default page; export default Page;

View File

@@ -1,10 +1,9 @@
import { ArrowRight, NotebookText } from "lucide-react";
import Link from "next/link";
import Events from "./_components/Events"; import Events from "./_components/Events";
import { Stats } from "./_components/Stats"; import { Stats } from "./_components/Stats";
import { Badges } from "./_components/Badges"; import { Badges } from "./_components/Badges";
import { getServerSession } from "api/auth/[...nextauth]/auth"; import { getServerSession } from "api/auth/[...nextauth]/auth";
import { EmailVerification } from "_components/EmailVerification"; import { EmailVerification } from "_components/EmailVerification";
import { RecentFlights } from "(app)/_components/RecentFlights";
export default async function Home({ export default async function Home({
searchParams, searchParams,
@@ -20,16 +19,7 @@ export default async function Home({
<Stats stats={view} /> <Stats stats={view} />
<div className="grid grid-cols-6 gap-4"> <div className="grid grid-cols-6 gap-4">
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3"> <div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<div className="card-body"> <RecentFlights />
<h2 className="card-title justify-between">
<span className="card-title">
<NotebookText className="w-4 h-4" /> Logbook
</span>
<Link className="badge badge-sm badge-info badge-outline" href="/logbook">
Zum vollständigen Logbook <ArrowRight className="w-4 h-4" />
</Link>
</h2>
</div>
</div> </div>
<Badges /> <Badges />
</div> </div>

View File

@@ -20,7 +20,7 @@ import {
LockOpen1Icon, LockOpen1Icon,
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { UserOptionalDefaults, UserOptionalDefaultsSchema, UserSchema } from "@repo/db/zod"; import { UserOptionalDefaults, UserOptionalDefaultsSchema } from "@repo/db/zod";
import { Bell, Plane } from "lucide-react"; import { Bell, Plane } from "lucide-react";
export const ProfileForm = ({ user }: { user: User }) => { export const ProfileForm = ({ user }: { user: User }) => {
@@ -30,6 +30,7 @@ export const ProfileForm = ({ user }: { user: User }) => {
email: z.string().email({ email: z.string().email({
message: "Bitte gebe eine gültige E-Mail Adresse ein", message: "Bitte gebe eine gültige E-Mail Adresse ein",
}), }),
settingsHideLastname: z.boolean().default(false),
}); });
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
type IFormInput = z.infer<typeof schema>; type IFormInput = z.infer<typeof schema>;
@@ -39,6 +40,7 @@ export const ProfileForm = ({ user }: { user: User }) => {
firstname: user.firstname, firstname: user.firstname,
lastname: user.lastname, lastname: user.lastname,
email: user.email, email: user.email,
settingsHideLastname: user.settingsHideLastname,
}, },
resolver: zodResolver(schema), resolver: zodResolver(schema),
}); });
@@ -92,7 +94,11 @@ export const ProfileForm = ({ user }: { user: User }) => {
{form.formState.errors.lastname && ( {form.formState.errors.lastname && (
<p className="text-error">{form.formState.errors.lastname?.message}</p> <p className="text-error">{form.formState.errors.lastname?.message}</p>
)} )}
<label className="floating-label w-full"> <label className="label">
<input type="checkbox" {...form.register("settingsHideLastname")} className="checkbox" />
Initialien des Nachnamens verstecken
</label>
<label className="floating-label w-full mt-4">
<span className="text-lg flex items-center gap-2"> <span className="text-lg flex items-center gap-2">
<EnvelopeClosedIcon /> E-Mail <EnvelopeClosedIcon /> E-Mail
</span> </span>

View File

@@ -11,7 +11,7 @@
"generate": "npx prisma generate && npx prisma generate zod", "generate": "npx prisma generate && npx prisma generate zod",
"migrate": "npx prisma migrate dev", "migrate": "npx prisma migrate dev",
"deploy": "npx prisma migrate deploy", "deploy": "npx prisma migrate deploy",
"dev": "npx prisma studio" "dev": "npx prisma studio --browser none"
}, },
"exports": { "exports": {
".": "./index.ts", ".": "./index.ts",

View File

@@ -37,4 +37,29 @@ export interface MissionMessageLog {
}; };
} }
export type MissionLog = MissionStationLog | MissionMessageLog | MissionSdsLog; export interface MissionAlertLog {
type: "alert-log";
auto: false;
timeStamp: string;
data: {
station?: Station;
vehicle?: string;
user: PublicUser;
};
}
export interface MissionCompletedLog {
type: "completed-log";
auto: boolean;
timeStamp: string;
data: {
user?: PublicUser;
};
}
export type MissionLog =
| MissionStationLog
| MissionMessageLog
| MissionSdsLog
| MissionAlertLog
| MissionCompletedLog;

View File

@@ -8,13 +8,21 @@ export interface PublicUser {
fullName: string; fullName: string;
} }
export const getPublicUser = (user: User): PublicUser => { export const getPublicUser = (
user: User,
options = {
ignorePrivacy: false,
},
): PublicUser => {
return { return {
firstname: user.firstname, firstname: user.firstname,
lastname: user.lastname lastname:
.split(" ") user.settingsHideLastname && !options.ignorePrivacy
.map((part) => `${part[0]}.`) ? ""
.join(" "), // Only take the first part of the name : user.lastname
.split(" ")
.map((part) => `${part[0]}.`)
.join(" "), // Only take the first part of the name
fullName: `${user.firstname} ${user.lastname fullName: `${user.firstname} ${user.lastname
.split(" ") .split(" ")
.map((part) => `${part[0]}.`) .map((part) => `${part[0]}.`)

View File

@@ -30,14 +30,15 @@ model User {
moodleId Int? @map(name: "moodle_id") moodleId Int? @map(name: "moodle_id")
// Settings: // Settings:
settingsNtfyRoom String? @map(name: "settings_ntfy_room") settingsNtfyRoom String? @map(name: "settings_ntfy_room")
settingsMicDevice String? @map(name: "settings_mic_device") settingsMicDevice String? @map(name: "settings_mic_device")
settingsMicVolume Int? @map(name: "settings_mic_volume") settingsMicVolume Int? @map(name: "settings_mic_volume")
settingsHideLastname Boolean @default(false) @map(name: "settings_hide_lastname")
// email Verification: // email Verification:
emailVerificationToken String? @map(name: "email_verification_token") emailVerificationToken String? @map(name: "email_verification_token")
emailVerificationExpiresAt DateTime? @map(name: "email_verification_expires_at") emailVerificationExpiresAt DateTime? @map(name: "email_verification_expires_at")
emailVerified Boolean? @default(false) emailVerified Boolean @default(false)
image String? image String?
badges BADGES[] @default([]) badges BADGES[] @default([])