added chron to hub server, removed starterEvent

This commit is contained in:
PxlLoewe
2025-02-28 07:21:07 +01:00
parent bbcde2eb4a
commit a477b65c2f
20 changed files with 584 additions and 281 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ node_modules
.pnp.js .pnp.js
migrations migrations
mkcert
# Local env files # Local env files
.env .env

View File

@@ -99,3 +99,5 @@ LocalMachine RemoteSigned
4. http://localhost:8081/admin/category.php?category=authsettings -> Guest login button -> Hide 4. http://localhost:8081/admin/category.php?category=authsettings -> Guest login button -> Hide
5. http://localhost:8081/admin/settings.php?section=sitepolicies -> emailchangeconfirmation -> False 5. http://localhost:8081/admin/settings.php?section=sitepolicies -> emailchangeconfirmation -> False
6. Beim anlegen des Auth-Services Require Email verification deaktivieren 6. Beim anlegen des Auth-Services Require Email verification deaktivieren
7. Beim erstellen der Role für API user: permission moodle/site:viewuseridentity geben
8. API user zu moodle kursen hinzufügen, um andere Nutzer hinzuzufügen

View File

@@ -0,0 +1,2 @@
MOODLE_TOKEN=
MOODLE_URL=

View File

@@ -0,0 +1,11 @@
import { Event, Participant } from "@repo/db";
export const participantCompleted = (
event: Event,
participant: Participant,
) => {
if (event.finisherMoodleCourseId && !participant.finisherMoodleCurseCompleted)
return false;
if (event.hasPresenceEvents && !participant.attended) return false;
return true;
};

View File

@@ -1,2 +1,5 @@
import "dotenv/config";
import "modules/chron"; import "modules/chron";
// Add API eventually
console.log("VAR hub Server started"); console.log("VAR hub Server started");

View File

@@ -1,6 +1,7 @@
import { getMoodleCourseCompletionStatus, getMoodleUserById } from "./moodle"; import { getMoodleCourseCompletionStatus, getMoodleUserById } from "./moodle";
import { CronJob } from "cron"; import { CronJob } from "cron";
import { prisma } from "@repo/db"; import { prisma } from "@repo/db";
import { participantCompleted } from "helper/event";
const syncMoodleIds = async () => { const syncMoodleIds = async () => {
try { try {
@@ -30,71 +31,84 @@ const syncMoodleIds = async () => {
} }
}; };
const updateParticipantMoodleResults = const updateParticipantMoodleResults = async () => {
(usage: "starter" | "finisher") => async () => { const participantsMoodlePending = await prisma.participant.findMany({
const participantsMoodlePending = await prisma.participant.findMany({ where: {
where: { finisherMoodleCurseCompleted: false,
[usage === "starter" Event: {
? "starterMoodleCourseCompleted" finisherMoodleCourseId: {
: "finisherMoodleCourseCompleted"]: false, not: null,
Event: {
[usage === "starter"
? "starterMoodleCourseId"
: "finisherMoodleCourseId"]: {
not: null,
},
}, },
}, },
select: { },
id: true, include: {
Event: true, Event: true,
User: true, User: true,
}, },
}); });
await Promise.all(
participantsMoodlePending.map(async (p) => {
if (!p.User) return;
if (!p.User.moodleId) return;
await Promise.all( const quizzResult = await getMoodleCourseCompletionStatus(
participantsMoodlePending.map(async (p) => { p.User.moodleId.toString(),
if (!p.User) return; p.Event.finisherMoodleCourseId!,
if (!p.User.moodleId) return; );
const quizzResult = await getMoodleCourseCompletionStatus( if (quizzResult?.completionstatus?.completed === true) {
p.User.moodleId.toString(), await prisma.participant.update({
p.Event[ where: {
usage === "starter" id: p.id,
? "starterMoodleCourseId" },
: "finisherMoodleCourseId" data: {
]!, finisherMoodleCurseCompleted: true,
); statusLog: {
p.Event.finisherMoodleCourseId; push: {
if (quizzResult?.completionstatus?.completed === true) { event: "Finisher course completed",
timestamp: new Date(),
user: "system",
},
},
},
});
if (participantCompleted(p.Event, p)) {
// Event is completed, give relating permissions
await prisma.user.update({
where: {
id: p.userId,
},
data: {
permissions: {
push: p.Event.finishedPermissions,
},
badges: {
push: p.Event.finishedBadges,
},
},
});
await prisma.participant.update({ await prisma.participant.update({
where: { where: {
id: p.id, id: p.id,
}, },
data: { data: {
[usage === "starter" finished: true,
? "starterMoodleCurseCompleted"
: "finisherMoodleCurseCompleted"]: true,
statusLog: {
push: {
event: "Starter course completed",
timestamp: new Date(),
user: "system",
},
},
}, },
}); });
} }
}), }
); }),
}; );
};
CronJob.from({ cronTime: "0 * * * *", onTick: syncMoodleIds, start: true }); CronJob.from({ cronTime: "0 * * * *", onTick: syncMoodleIds, start: true });
CronJob.from({ CronJob.from({
cronTime: "*/5 * * * *", cronTime: "*/5 * * * *",
onTick: async () => { onTick: async () => {
await updateParticipantMoodleResults("starter"); console.log("Updating participant moodle results");
await updateParticipantMoodleResults("finisher"); await updateParticipantMoodleResults();
}, },
start: true, start: true,
}); });
updateParticipantMoodleResults();

View File

@@ -2,7 +2,7 @@ import axios from "axios";
export const getMoodleUserById = async (id: string) => { export const getMoodleUserById = async (id: string) => {
const { data: user } = await axios.get( const { data: user } = await axios.get(
"https://moodle.virtualairrescue.com/webservice/rest/server.php", `${process.env.MOODLE_URL}/webservice/rest/server.php`,
{ {
params: { params: {
wstoken: process.env.MOODLE_TOKEN, wstoken: process.env.MOODLE_TOKEN,
@@ -31,7 +31,7 @@ export const getMoodleUserById = async (id: string) => {
export const getMoodleQuizResult = async (userId: string, quizId: string) => { export const getMoodleQuizResult = async (userId: string, quizId: string) => {
const { data: quizzes } = await axios.get( const { data: quizzes } = await axios.get(
"https://moodle.virtualairrescue.com/webservice/rest/server.php", `${process.env.MOODLE_URL}/webservice/rest/server.php`,
{ {
params: { params: {
wstoken: process.env.MOODLE_TOKEN, wstoken: process.env.MOODLE_TOKEN,
@@ -50,7 +50,7 @@ export const getMoodleCourseCompletionStatus = async (
courseId: string, courseId: string,
) => { ) => {
const { data: completionStatus } = await axios.get( const { data: completionStatus } = await axios.get(
"https://moodle.virtualairrescue.com/webservice/rest/server.php", `${process.env.MOODLE_URL}/webservice/rest/server.php`,
{ {
params: { params: {
wstoken: process.env.MOODLE_TOKEN, wstoken: process.env.MOODLE_TOKEN,
@@ -61,29 +61,11 @@ export const getMoodleCourseCompletionStatus = async (
}, },
}, },
); );
return completionStatus; return completionStatus as {
}; completionstatus: {
completed: true;
export const enrollUserInCourse = async ( aggregation: number;
courseid: number | string, };
userid: number | string, warnings: [];
) => { };
const { data: enrollmentResponse } = await axios.get(
"https://moodle.virtualairrescue.com/webservice/rest/server.php",
{
params: {
wstoken: process.env.MOODLE_TOKEN,
wsfunction: "enrol_manual_enrol_users",
moodlewsrestformat: "json",
enrolments: [
{
roleid: 5,
userid,
courseid,
},
],
},
},
);
return enrollmentResponse;
}; };

View File

@@ -1,11 +1,15 @@
{ {
"name": "hub-server", "name": "hub-server",
"exports": {
"helpers": "./helper"
},
"scripts": { "scripts": {
"dev": "nodemon", "dev": "nodemon",
"build": "tsc" "build": "tsc"
}, },
"devDependencies": { "devDependencies": {
"@repo/db": "*", "@repo/db": "*",
"@repo/hub": "*",
"@repo/typescript-config": "*", "@repo/typescript-config": "*",
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
@@ -13,6 +17,7 @@
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.9", "axios": "^1.7.9",
"cron": "^4.1.0" "cron": "^4.1.0",
"dotenv": "^16.4.7"
} }
} }

View File

@@ -5,4 +5,6 @@ DISCORD_OAUTH_CLIENT_ID=
DISCORD_OAUTH_SECRET= DISCORD_OAUTH_SECRET=
DISCORD_BOT_TOKEN= DISCORD_BOT_TOKEN=
NEXT_PUBLIC_DISCORD_URL= NEXT_PUBLIC_DISCORD_URL=
DISCORD_REDIRECT= DISCORD_REDIRECT=
MOODLE_TOKEN=
NEXT_PUBLIC_MOODLE_URL=

View File

@@ -8,7 +8,7 @@ import {
ParticipantOptionalDefaultsSchema, ParticipantOptionalDefaultsSchema,
} from "@repo/db/zod"; } from "@repo/db/zod";
import { set, useForm } from "react-hook-form"; import { set, useForm } from "react-hook-form";
import { BADGES, Event, EventAppointment, User } from "@repo/db"; import { BADGES, Event, EVENT_TYPE, PERMISSION } from "@repo/db";
import { Bot, Calendar, FileText, UserIcon } from "lucide-react"; import { Bot, Calendar, FileText, UserIcon } from "lucide-react";
import { Input } from "../../../../_components/ui/Input"; import { Input } from "../../../../_components/ui/Input";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
@@ -60,7 +60,6 @@ export const Form = ({ event }: { event?: Event }) => {
eventId: event?.id, eventId: event?.id,
presenterId: values.presenterId, presenterId: values.presenterId,
}); });
console.log(createdAppointment);
addParticipantModal.current?.close(); addParticipantModal.current?.close();
appointmentsTableRef.current?.refresh(); appointmentsTableRef.current?.refresh();
})} })}
@@ -83,6 +82,7 @@ export const Form = ({ event }: { event?: Event }) => {
<form <form
onSubmit={form.handleSubmit(async (values) => { onSubmit={form.handleSubmit(async (values) => {
setLoading(true); setLoading(true);
const createdEvent = await upsertEvent(values, event?.id); const createdEvent = await upsertEvent(values, event?.id);
setLoading(false); setLoading(false);
if (!event) redirect(`/admin/event`); if (!event) redirect(`/admin/event`);
@@ -105,6 +105,15 @@ export const Form = ({ event }: { event?: Event }) => {
})} })}
/> />
<Switch form={form} name="hidden" label="Versteckt" /> <Switch form={form} name="hidden" label="Versteckt" />
<Select
form={form}
name="type"
label="Typ"
options={Object.entries(EVENT_TYPE).map(([key, value]) => ({
label: key,
value: value,
}))}
/>
</div> </div>
</div> </div>
<div className="card bg-base-200 shadow-xl col-span-3 max-xl:col-span-6"> <div className="card bg-base-200 shadow-xl col-span-3 max-xl:col-span-6">
@@ -112,12 +121,6 @@ export const Form = ({ event }: { event?: Event }) => {
<h2 className="card-title"> <h2 className="card-title">
<Bot className="w-5 h-5" /> Automation <Bot className="w-5 h-5" /> Automation
</h2> </h2>
<Input
form={form}
name="starterMoodleCourseId"
label="Moodle Anmelde Kurs ID"
className="input-sm"
/>
<Input <Input
name="finisherMoodleCourseId" name="finisherMoodleCourseId"
form={form} form={form}
@@ -144,6 +147,16 @@ export const Form = ({ event }: { event?: Event }) => {
value: key, value: key,
}))} }))}
/> />
<Select
isMulti
form={form}
name="finishedPermissions"
label="Berechtigungen bei Abschluss"
options={Object.entries(PERMISSION).map(([key, value]) => ({
label: value,
value: key,
}))}
/>
<Switch <Switch
form={form} form={form}
name="hasPresenceEvents" name="hasPresenceEvents"

View File

@@ -1,10 +1,19 @@
"use client"; "use client";
import { DrawingPinFilledIcon, EnterIcon } from "@radix-ui/react-icons"; import { DrawingPinFilledIcon, EnterIcon } from "@radix-ui/react-icons";
import { Event, User } from "@repo/db"; import { Event, Participant, EventAppointment, User } from "@repo/db";
import ModalBtn from "./modalBtn"; import ModalBtn from "./modalBtn";
import MDEditor from "@uiw/react-md-editor"; import MDEditor from "@uiw/react-md-editor";
export const KursItem = ({ user, event }: { user: User; event: Event }) => { export const KursItem = ({
user,
event,
}: {
user: User;
event: Event & {
appointments: EventAppointment[];
participants: Participant[];
};
}) => {
return ( return (
<div className="col-span-full"> <div className="col-span-full">
<div className="card bg-base-200 shadow-xl mb-4"> <div className="card bg-base-200 shadow-xl mb-4">
@@ -56,8 +65,10 @@ export const KursItem = ({ user, event }: { user: User; event: Event }) => {
)} )}
</div> </div>
<ModalBtn <ModalBtn
user={user}
event={event}
title={event.name} title={event.name}
dates={["Dienstag, 25 Februar 2025", "Mittwoch, 26 Februar 2025"]} dates={event.appointments}
modalId={`${event.name}_modal.${event.id}`} modalId={`${event.name}_modal.${event.id}`}
/> />
</div> </div>
@@ -67,7 +78,13 @@ export const KursItem = ({ user, event }: { user: User; event: Event }) => {
); );
}; };
export const PilotKurs = ({ user }: { user: User }) => { export const ObligatedEvent = ({
event,
user,
}: {
event: Event;
user: User;
}) => {
{ {
/* STATISCH, DA FÜR ALLE NEUEN MITGLIEDER MANDATORY, WIRD AUSGEBLENDET WENN ABSOLVIERT */ /* STATISCH, DA FÜR ALLE NEUEN MITGLIEDER MANDATORY, WIRD AUSGEBLENDET WENN ABSOLVIERT */
} }
@@ -96,9 +113,14 @@ export const PilotKurs = ({ user }: { user: User }) => {
<p className="text-gray-600 text-left flex items-center gap-2"> <p className="text-gray-600 text-left flex items-center gap-2">
<DrawingPinFilledIcon /> <b>Teilnahmevoraussetzungen:</b> Keine <DrawingPinFilledIcon /> <b>Teilnahmevoraussetzungen:</b> Keine
</p> </p>
<button className="btn btn-outline btn-secondary btn-wide"> <ModalBtn
<EnterIcon /> Zum Moodle Kurs user={user}
</button> event={event}
title={event.name}
dates={(event as any).appointments}
participant={(event as any).participants[0]}
modalId={`${event.name}_modal.${event.id}`}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,87 +1,156 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { import {
CheckCircledIcon, CheckCircledIcon,
CalendarIcon, CalendarIcon,
EnterIcon, EnterIcon,
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import { Event, EventAppointment, Participant, User } from "@repo/db";
import { cn } from "../../../../helper/cn";
import { addParticipant, inscribeToMoodleCourse } from "../actions";
import { useSession } from "next-auth/react";
interface ModalBtnProps { interface ModalBtnProps {
title: string; title: string;
dates: string[]; event: Event;
modalId: string; dates: EventAppointment[];
participant?: Participant;
user: User;
modalId: string;
} }
const ModalBtn = ({ title, dates, modalId }: ModalBtnProps) => { const ModalBtn = ({
useEffect(() => { title,
const modal = document.getElementById(modalId) as HTMLDialogElement; dates,
const handleOpen = () => { modalId,
document.body.classList.add("modal-open"); participant,
}; event,
const handleClose = () => { user,
document.body.classList.remove("modal-open"); }: ModalBtnProps) => {
}; useEffect(() => {
modal?.addEventListener("show", handleOpen); const modal = document.getElementById(modalId) as HTMLDialogElement;
modal?.addEventListener("close", handleClose); const handleOpen = () => {
return () => { document.body.classList.add("modal-open");
modal?.removeEventListener("show", handleOpen); };
modal?.removeEventListener("close", handleClose); const handleClose = () => {
}; document.body.classList.remove("modal-open");
}, [modalId]); };
modal?.addEventListener("show", handleOpen);
modal?.addEventListener("close", handleClose);
return () => {
modal?.removeEventListener("show", handleOpen);
modal?.removeEventListener("close", handleClose);
};
}, [modalId]);
const openModal = () => { const openModal = () => {
const modal = document.getElementById(modalId) as HTMLDialogElement; const modal = document.getElementById(modalId) as HTMLDialogElement;
document.body.classList.add("modal-open"); document.body.classList.add("modal-open");
modal?.showModal(); modal?.showModal();
}; };
const closeModal = () => { const closeModal = () => {
const modal = document.getElementById(modalId) as HTMLDialogElement; const modal = document.getElementById(modalId) as HTMLDialogElement;
document.body.classList.remove("modal-open"); document.body.classList.remove("modal-open");
modal?.close(); modal?.close();
}; };
return ( return (
<> <>
<button className="btn btn-outline btn-info btn-wide" onClick={openModal}> <button
<EnterIcon /> Anmelden className={cn(
</button> "btn btn-outline btn-info btn-wide",
<dialog id={modalId} className="modal"> event.type === "OBLIGATED_COURSE" && "btn-secondary",
<div className="modal-box"> )}
<h3 className="font-bold text-lg">{title}</h3> onClick={openModal}
<p className="py-4 flex items-center gap-2 justify-center"> >
<CheckCircledIcon className="text-success" /> <EnterIcon /> Anmelden
Moodle Kurs abgeschlossen </button>
</p> <dialog id={modalId} className="modal">
<div> <div className="modal-box">
<div className="flex items-center gap-2 justify-center"> <h3 className="font-bold text-lg">{title}</h3>
<CalendarIcon /> {event.hasPresenceEvents && (
<select className="select w-full max-w-xs" defaultValue={0}> <div>
<option disabled>Bitte wähle einen Termin aus</option> <div className="flex items-center gap-2 justify-center">
{dates.map((date, index) => ( <CalendarIcon />
<option key={index}>{date}</option> <select className="select w-full max-w-xs" defaultValue={0}>
))} <option disabled>Bitte wähle einen Termin aus</option>
</select> {dates.map((date, index) => (
</div> <option key={index}>
<p className="mt-3 text-center"> {date.appointmentDate.toLocaleString()}
Bitte finde dich an diesem Termin in unserem Discord ein. </option>
</p> ))}
</div> </select>
<div className="modal-action flex justify-between"> </div>
<button className="btn" onClick={closeModal}> <p className="mt-3 text-center">
Abbrechen Bitte finde dich an diesem Termin in unserem Discord ein.
</button> </p>
<button className="btn btn-info btn-outline btn-wide"> </div>
<EnterIcon /> Anmelden )}
</button> {event.finisherMoodleCourseId && (
</div> <MoodleCourseIndicator
</div> user={user}
<button className="modal-backdrop" onClick={closeModal}> moodleCourseId={event.finisherMoodleCourseId}
Abbrechen completed={participant?.finisherMoodleCurseCompleted}
</button> eventId={event.id}
</dialog> />
</> )}
); <div className="modal-action flex justify-between">
<button className="btn" onClick={closeModal}>
Abbrechen
</button>
{event.hasPresenceEvents && (
<button className="btn btn-info btn-outline btn-wide">
<EnterIcon /> Anmelden
</button>
)}
</div>
</div>
<button className="modal-backdrop" onClick={closeModal}>
Abbrechen
</button>
</dialog>
</>
);
}; };
export default ModalBtn; export default ModalBtn;
const MoodleCourseIndicator = ({
completed,
moodleCourseId,
eventId,
user,
}: {
completed?: boolean;
moodleCourseId: string;
eventId: number;
user: User;
}) => {
const courseUrl = `${process.env.NEXT_PUBLIC_MOODLE_URL}/course/view.php?id=${moodleCourseId}`;
if (completed)
return (
<p className="py-4 flex items-center gap-2 justify-center">
<CheckCircledIcon className="text-success" />
Moodle Kurs abgeschlossen
</p>
);
return (
<p className="py-4 flex items-center gap-2 justify-center">
Moodle-Kurs Benötigt
<button
className="btn btn-xs btn-info ml-2"
onClick={async () => {
await addParticipant(eventId, user.id);
if (user.moodleId) {
await inscribeToMoodleCourse(moodleCourseId, user.moodleId);
}
window.open(courseUrl, "_blank");
}}
>
Zum Moodle Kurs
</button>
</p>
);
};

View File

@@ -0,0 +1,26 @@
"use server";
import { enrollUserInCourse } from "../../../helper/moodle";
import { prisma } from "@repo/db";
export const inscribeToMoodleCourse = async (
moodleCourseId: string | number,
userMoodleId: string | number,
) => {
await enrollUserInCourse(moodleCourseId, userMoodleId);
};
export const addParticipant = async (eventId: number, userId: string) => {
const participant = await prisma.participant.findFirst({
where: {
userId: userId,
},
});
if (!participant) {
await prisma.participant.create({
data: {
userId: userId,
eventId,
},
});
}
};

View File

@@ -1,27 +1,44 @@
import { getServerSession } from '../../api/auth/[...nextauth]/auth'; import { getServerSession } from "../../api/auth/[...nextauth]/auth";
import { PrismaClient } from '@repo/db'; import { PrismaClient } from "@repo/db";
import { PilotKurs, KursItem } from './_components/item'; import { ObligatedEvent, KursItem } from "./_components/item";
import { RocketIcon } from '@radix-ui/react-icons'; import { RocketIcon } from "@radix-ui/react-icons";
export default async () => { export default async () => {
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const session = await getServerSession(); const session = await getServerSession();
if (!session) return null; if (!session) return null;
const user = session.user; const user = await prisma.user.findUnique({
const events = await prisma.event.findMany(); where: {
if (!user) return null; id: session.user.id,
},
});
if (!user) return null;
return ( const events = await prisma.event.findMany({
<div className="grid grid-cols-6 gap-4"> include: {
<div className="col-span-full"> appointments: true,
<p className="text-2xl font-semibold text-left flex items-center gap-2"> participants: {
<RocketIcon className="w-5 h-5" /> Events & Kurse where: {
</p> userId: user.id,
</div> },
<PilotKurs user={user} /> },
{events.map((event) => ( },
<KursItem user={user} event={event} key={event.id} /> });
))}
</div> return (
); <div className="grid grid-cols-6 gap-4">
<div className="col-span-full">
<p className="text-2xl font-semibold text-left flex items-center gap-2">
<RocketIcon className="w-5 h-5" /> Events & Kurse
</p>
</div>
{events.map((event) => {
if (event.type === "OBLIGATED_COURSE")
return <ObligatedEvent user={user} event={event} key={event.id} />;
if (event.type === "COURSE")
return <KursItem user={user} event={event} key={event.id} />;
})}
</div>
);
}; };

View File

@@ -1,62 +1,103 @@
'use client'; "use client";
import { import {
FieldValues, FieldValues,
Path, Path,
RegisterOptions, RegisterOptions,
UseFormReturn, UseFormReturn,
} from 'react-hook-form'; } from "react-hook-form";
import SelectTemplate, { Props as SelectTemplateProps } from 'react-select'; import SelectTemplate, {
import { cn } from '../../../helper/cn'; Props as SelectTemplateProps,
import dynamic from 'next/dynamic'; StylesConfig,
} from "react-select";
import { cn } from "../../../helper/cn";
import dynamic from "next/dynamic";
interface SelectProps<T extends FieldValues> { interface SelectProps<T extends FieldValues> {
name: Path<T>; name: Path<T>;
form: UseFormReturn<T>; form: UseFormReturn<T>;
formOptions?: RegisterOptions<T>; formOptions?: RegisterOptions<T>;
label?: string; label?: string;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
} }
const customStyles: StylesConfig<any, false> = {
control: (provided) => ({
...provided,
backgroundColor: "var(--color-base-100)",
borderColor: "color-mix(in oklab, var(--color-base-content) 20%, #0000);",
borderRadius: "0.5rem",
padding: "0.25rem",
boxShadow: "none",
"&:hover": {
borderColor: "color-mix(in oklab, var(--color-base-content) 20%, #0000);",
},
}),
option: (provided, state) => ({
...provided,
backgroundColor: state.isSelected ? "hsl(var(--p))" : "hsl(var(--b1))",
color: "var(--color-primary-content)",
"&:hover": { backgroundColor: "var(--color-base-200)" }, // DaisyUI secondary color
}),
multiValueLabel: (provided) => ({
...provided,
color: "var(--color-primary-content)",
}),
singleValue: (provided) => ({
...provided,
color: "var(--color-primary-content)",
}),
multiValue: (provided) => ({
...provided,
backgroundColor: "var(--color-base-300)",
}),
menu: (provided) => ({
...provided,
backgroundColor: "var(--color-base-100)",
borderRadius: "0.5rem",
}),
};
const SelectCom = <T extends FieldValues>({ const SelectCom = <T extends FieldValues>({
name, name,
label = name, label = name,
placeholder = label, placeholder = label,
form, form,
formOptions, formOptions,
className, className,
...inputProps ...inputProps
}: SelectProps<T>) => { }: SelectProps<T>) => {
return ( return (
<div> <div>
<span className="label-text text-lg flex items-center gap-2"> <span className="label-text text-lg flex items-center gap-2">
{label} {label}
</span> </span>
<SelectTemplate <SelectTemplate
{...form.register(name, formOptions)} {...form.register(name, formOptions)}
onChange={(newValue: any) => { onChange={(newValue: any) => {
if ('value' in newValue) { if (Array.isArray(newValue)) {
form.setValue(name, newValue.value); form.setValue(name, newValue.map((v: any) => v.value) as any);
} else { } else {
form.setValue(name, newValue); form.setValue(name, newValue.value);
} }
form.trigger(name); form.trigger(name);
}} }}
className={cn('w-full placeholder:text-neutral-600', className)} styles={customStyles}
placeholder={placeholder} className={cn("w-full placeholder:text-neutral-600", className)}
{...inputProps} placeholder={placeholder}
/> {...inputProps}
{form.formState.errors[name] && ( />
<p className="text-error"> {form.formState.errors[name]?.message && (
{form.formState.errors[name].message as string} <p className="text-error">
</p> {form.formState.errors[name].message as string}
)} </p>
</div> )}
); </div>
);
}; };
const SelectWrapper = (props: any) => <SelectCom {...props} />; const SelectWrapper = (props: any) => <SelectCom {...props} />;
export const Select = dynamic(() => Promise.resolve(SelectWrapper), { export const Select = dynamic(() => Promise.resolve(SelectWrapper), {
ssr: false, ssr: false,
}); });

View File

@@ -1,26 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "../auth/[...nextauth]/auth";
import { prisma } from "@repo/db"; import { prisma } from "@repo/db";
import { generateToken } from "../../(auth)/oauth/_components/action"; import { verify } from "jsonwebtoken";
import { decode, verify } from "jsonwebtoken"; import { getMoodleUserById } from "../../../helper/moodle";
import { inscribeToMoodleCourse } from "../../(app)/events/actions";
export async function middleware(req: NextRequest) {
const authHeader = req.headers.get("authorization");
if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.split(" ")[1];
if (token) {
// Hier kannst du den Token validieren (optional)
req.headers.set("x-next-auth-token", token);
}
// Falls NextAuth keine Session hat, erstellen wir eine Fake-Session
}
return NextResponse.next();
}
export const GET = async (req: NextRequest) => { export const GET = async (req: NextRequest) => {
// This route is only used by Moodle, so NextAuth is not used here // This route is only used by Moodle, so NextAuth is not used here
@@ -39,6 +21,42 @@ export const GET = async (req: NextRequest) => {
id: decoded.id, id: decoded.id,
}, },
}); });
if (!user)
return NextResponse.json({ error: "User not found" }, { status: 404 });
setTimeout(async () => {
const moodleUser = await getMoodleUserById(user.id);
prisma.user.update({
where: {
id: user.id,
},
data: {
moodleId: moodleUser?.id,
},
});
if (user.moodleId) return;
const participatingEvents = await prisma.participant.findMany({
where: {
userId: user.id,
Event: {
finisherMoodleCourseId: {
not: null,
},
},
},
include: {
Event: true,
},
});
participatingEvents.forEach(async (p) => {
await inscribeToMoodleCourse(
p.Event.finisherMoodleCourseId!,
moodleUser?.id,
);
});
}, 1000);
return NextResponse.json({ return NextResponse.json({
...user, ...user,

11
apps/hub/helper/event.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Event, Participant } from "@repo/db";
export const participantCompleted = (
event: Event,
participant: Participant,
) => {
if (event.finisherMoodleCourseId && !participant.finisherMoodleCurseCompleted)
return false;
if (event.hasPresenceEvents && !participant.attended) return false;
return true;
};

56
apps/hub/helper/moodle.ts Normal file
View File

@@ -0,0 +1,56 @@
import axios from "axios";
export const enrollUserInCourse = async (
courseid: number | string,
userid: number | string,
) => {
const { data: enrollmentResponse } = await axios.get(
`${process.env.NEXT_PUBLIC_MOODLE_URL}/webservice/rest/server.php`,
{
params: {
wstoken: process.env.MOODLE_TOKEN,
wsfunction: "enrol_manual_enrol_users",
moodlewsrestformat: "json",
enrolments: [
{
roleid: 5,
userid: typeof userid === "string" ? parseInt(userid) : userid,
courseid:
typeof courseid === "string" ? parseInt(courseid) : courseid,
},
],
},
},
);
if (enrollmentResponse !== null) console.error(enrollmentResponse);
return enrollmentResponse;
};
export const getMoodleUserById = async (id: string) => {
const { data: user } = await axios.get(
`${process.env.NEXT_PUBLIC_MOODLE_URL}/webservice/rest/server.php`,
{
params: {
wstoken: process.env.MOODLE_TOKEN,
wsfunction: "core_user_get_users_by_field",
moodlewsrestformat: "json",
field: "idnumber",
values: [id],
},
paramsSerializer: {
indexes: true, // use brackets with indexes
},
},
);
const u = user[0];
return (
(u as {
id: number;
username: string;
firstname: string;
lastname: string;
fullname: string;
email: string;
}) || null
);
};

15
package-lock.json generated
View File

@@ -136,7 +136,8 @@
"apps/hub-server": { "apps/hub-server": {
"dependencies": { "dependencies": {
"axios": "^1.7.9", "axios": "^1.7.9",
"cron": "^4.1.0" "cron": "^4.1.0",
"dotenv": "^16.4.7"
}, },
"devDependencies": { "devDependencies": {
"@repo/db": "*", "@repo/db": "*",
@@ -156,6 +157,18 @@
"undici-types": "~6.20.0" "undici-types": "~6.20.0"
} }
}, },
"apps/hub-server/node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"apps/hub-server/node_modules/typescript": { "apps/hub-server/node_modules/typescript": {
"version": "5.7.3", "version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",

View File

@@ -1,13 +1,7 @@
enum PARTICIPANT_STATUS { enum EVENT_TYPE {
WAITING_FOR_ENTRY_TEST COURSE
ENTRY_TEST_FAILED OBLIGATED_COURSE
READY_FOR_EVENT EVENT
PARTICIPATED
WAITING_FOR_EXIT_TEST
EXIT_TEST_FAILED
WAITING_FOR_PERMISISONS
FINISHED
WAVED
} }
model EventAppointment { model EventAppointment {
@@ -25,8 +19,9 @@ model EventAppointment {
model Participant { model Participant {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId String @map(name: "user_id") userId String @map(name: "user_id")
starterMoodleCurseCompleted Boolean @default(false)
finisherMoodleCurseCompleted Boolean @default(false) finisherMoodleCurseCompleted Boolean @default(false)
attended Boolean @default(false)
finished Boolean @default(false)
eventAppointmentId Int? eventAppointmentId Int?
statusLog Json[] statusLog Json[]
eventId Int eventId Int
@@ -37,18 +32,18 @@ model Participant {
} }
model Event { model Event {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
description String description String
discordRoleId String? @default("") type EVENT_TYPE @default(EVENT)
hasPresenceEvents Boolean @default(false) discordRoleId String? @default("")
maxParticipants Int? @default(0) hasPresenceEvents Boolean @default(false)
starterMoodleCourseId String? @default("") maxParticipants Int? @default(0)
finisherMoodleCourseId String? @default("") finisherMoodleCourseId String? @default("")
finishedBadges String[] @default([]) finishedBadges BADGES[] @default([])
requiredBadges String[] @default([]) requiredBadges BADGES[] @default([])
finishedPermissions String[] @default([]) finishedPermissions PERMISSION[] @default([])
hidden Boolean @default(true) hidden Boolean @default(true)
// relations: // relations:
participants Participant[] participants Participant[]