diff --git a/.gitignore b/.gitignore index 0e3eb7ed..34a89ac2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules .pnp.js migrations +mkcert # Local env files .env diff --git a/README.md b/README.md index 2c6898cb..47f603fb 100644 --- a/README.md +++ b/README.md @@ -99,3 +99,5 @@ LocalMachine RemoteSigned 4. http://localhost:8081/admin/category.php?category=authsettings -> Guest login button -> Hide 5. http://localhost:8081/admin/settings.php?section=sitepolicies -> emailchangeconfirmation -> False 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 diff --git a/apps/hub-server/.env.example b/apps/hub-server/.env.example new file mode 100644 index 00000000..67a41c4e --- /dev/null +++ b/apps/hub-server/.env.example @@ -0,0 +1,2 @@ +MOODLE_TOKEN= +MOODLE_URL= \ No newline at end of file diff --git a/apps/hub-server/helper/event.ts b/apps/hub-server/helper/event.ts new file mode 100644 index 00000000..11537fd7 --- /dev/null +++ b/apps/hub-server/helper/event.ts @@ -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; +}; diff --git a/apps/hub-server/index.ts b/apps/hub-server/index.ts index 75efaea0..7cc534dc 100644 --- a/apps/hub-server/index.ts +++ b/apps/hub-server/index.ts @@ -1,2 +1,5 @@ +import "dotenv/config"; import "modules/chron"; + +// Add API eventually console.log("VAR hub Server started"); diff --git a/apps/hub-server/modules/chron.ts b/apps/hub-server/modules/chron.ts index afbbbc6b..adeabdff 100644 --- a/apps/hub-server/modules/chron.ts +++ b/apps/hub-server/modules/chron.ts @@ -1,6 +1,7 @@ import { getMoodleCourseCompletionStatus, getMoodleUserById } from "./moodle"; import { CronJob } from "cron"; import { prisma } from "@repo/db"; +import { participantCompleted } from "helper/event"; const syncMoodleIds = async () => { try { @@ -30,71 +31,84 @@ const syncMoodleIds = async () => { } }; -const updateParticipantMoodleResults = - (usage: "starter" | "finisher") => async () => { - const participantsMoodlePending = await prisma.participant.findMany({ - where: { - [usage === "starter" - ? "starterMoodleCourseCompleted" - : "finisherMoodleCourseCompleted"]: false, - Event: { - [usage === "starter" - ? "starterMoodleCourseId" - : "finisherMoodleCourseId"]: { - not: null, - }, +const updateParticipantMoodleResults = async () => { + const participantsMoodlePending = await prisma.participant.findMany({ + where: { + finisherMoodleCurseCompleted: false, + Event: { + finisherMoodleCourseId: { + not: null, }, }, - select: { - id: true, - Event: true, - User: true, - }, - }); + }, + include: { + Event: true, + User: true, + }, + }); + await Promise.all( + participantsMoodlePending.map(async (p) => { + if (!p.User) return; + if (!p.User.moodleId) return; - await Promise.all( - participantsMoodlePending.map(async (p) => { - if (!p.User) return; - if (!p.User.moodleId) return; + const quizzResult = await getMoodleCourseCompletionStatus( + p.User.moodleId.toString(), + p.Event.finisherMoodleCourseId!, + ); - const quizzResult = await getMoodleCourseCompletionStatus( - p.User.moodleId.toString(), - p.Event[ - usage === "starter" - ? "starterMoodleCourseId" - : "finisherMoodleCourseId" - ]!, - ); - p.Event.finisherMoodleCourseId; - if (quizzResult?.completionstatus?.completed === true) { + if (quizzResult?.completionstatus?.completed === true) { + await prisma.participant.update({ + where: { + id: p.id, + }, + data: { + finisherMoodleCurseCompleted: true, + statusLog: { + push: { + 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({ where: { id: p.id, }, data: { - [usage === "starter" - ? "starterMoodleCurseCompleted" - : "finisherMoodleCurseCompleted"]: true, - statusLog: { - push: { - event: "Starter course completed", - timestamp: new Date(), - user: "system", - }, - }, + finished: true, }, }); } - }), - ); - }; + } + }), + ); +}; CronJob.from({ cronTime: "0 * * * *", onTick: syncMoodleIds, start: true }); CronJob.from({ cronTime: "*/5 * * * *", onTick: async () => { - await updateParticipantMoodleResults("starter"); - await updateParticipantMoodleResults("finisher"); + console.log("Updating participant moodle results"); + await updateParticipantMoodleResults(); }, start: true, }); + +updateParticipantMoodleResults(); diff --git a/apps/hub-server/modules/moodle.ts b/apps/hub-server/modules/moodle.ts index c89aa4aa..fd3f733e 100644 --- a/apps/hub-server/modules/moodle.ts +++ b/apps/hub-server/modules/moodle.ts @@ -2,7 +2,7 @@ import axios from "axios"; export const getMoodleUserById = async (id: string) => { const { data: user } = await axios.get( - "https://moodle.virtualairrescue.com/webservice/rest/server.php", + `${process.env.MOODLE_URL}/webservice/rest/server.php`, { params: { wstoken: process.env.MOODLE_TOKEN, @@ -31,7 +31,7 @@ export const getMoodleUserById = async (id: string) => { export const getMoodleQuizResult = async (userId: string, quizId: string) => { const { data: quizzes } = await axios.get( - "https://moodle.virtualairrescue.com/webservice/rest/server.php", + `${process.env.MOODLE_URL}/webservice/rest/server.php`, { params: { wstoken: process.env.MOODLE_TOKEN, @@ -50,7 +50,7 @@ export const getMoodleCourseCompletionStatus = async ( courseId: string, ) => { const { data: completionStatus } = await axios.get( - "https://moodle.virtualairrescue.com/webservice/rest/server.php", + `${process.env.MOODLE_URL}/webservice/rest/server.php`, { params: { wstoken: process.env.MOODLE_TOKEN, @@ -61,29 +61,11 @@ export const getMoodleCourseCompletionStatus = async ( }, }, ); - return completionStatus; -}; - -export const enrollUserInCourse = async ( - courseid: number | string, - userid: number | string, -) => { - 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; + return completionStatus as { + completionstatus: { + completed: true; + aggregation: number; + }; + warnings: []; + }; }; diff --git a/apps/hub-server/package.json b/apps/hub-server/package.json index ce1401c7..d51f30fa 100644 --- a/apps/hub-server/package.json +++ b/apps/hub-server/package.json @@ -1,11 +1,15 @@ { "name": "hub-server", + "exports": { + "helpers": "./helper" + }, "scripts": { "dev": "nodemon", "build": "tsc" }, "devDependencies": { "@repo/db": "*", + "@repo/hub": "*", "@repo/typescript-config": "*", "@types/node": "^22.13.5", "concurrently": "^9.1.2", @@ -13,6 +17,7 @@ }, "dependencies": { "axios": "^1.7.9", - "cron": "^4.1.0" + "cron": "^4.1.0", + "dotenv": "^16.4.7" } } diff --git a/apps/hub/.env.example b/apps/hub/.env.example index ae7d750e..c66c3a3d 100644 --- a/apps/hub/.env.example +++ b/apps/hub/.env.example @@ -5,4 +5,6 @@ DISCORD_OAUTH_CLIENT_ID= DISCORD_OAUTH_SECRET= DISCORD_BOT_TOKEN= NEXT_PUBLIC_DISCORD_URL= -DISCORD_REDIRECT= \ No newline at end of file +DISCORD_REDIRECT= +MOODLE_TOKEN= +NEXT_PUBLIC_MOODLE_URL= \ No newline at end of file diff --git a/apps/hub/app/(app)/admin/event/_components/Form.tsx b/apps/hub/app/(app)/admin/event/_components/Form.tsx index e44eb1d6..59c97def 100644 --- a/apps/hub/app/(app)/admin/event/_components/Form.tsx +++ b/apps/hub/app/(app)/admin/event/_components/Form.tsx @@ -8,7 +8,7 @@ import { ParticipantOptionalDefaultsSchema, } from "@repo/db/zod"; 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 { Input } from "../../../../_components/ui/Input"; import { useRef, useState } from "react"; @@ -60,7 +60,6 @@ export const Form = ({ event }: { event?: Event }) => { eventId: event?.id, presenterId: values.presenterId, }); - console.log(createdAppointment); addParticipantModal.current?.close(); appointmentsTableRef.current?.refresh(); })} @@ -83,6 +82,7 @@ export const Form = ({ event }: { event?: Event }) => {
{ setLoading(true); + const createdEvent = await upsertEvent(values, event?.id); setLoading(false); if (!event) redirect(`/admin/event`); @@ -105,6 +105,15 @@ export const Form = ({ event }: { event?: Event }) => { })} /> + { value: key, }))} /> + - - {dates.map((date, index) => ( - - ))} - - -

- Bitte finde dich an diesem Termin in unserem Discord ein. -

- -
- - -
- - - - - ); + return ( + <> + + +
+

{title}

+ {event.hasPresenceEvents && ( +
+
+ + +
+

+ Bitte finde dich an diesem Termin in unserem Discord ein. +

+
+ )} + {event.finisherMoodleCourseId && ( + + )} +
+ + {event.hasPresenceEvents && ( + + )} +
+
+ +
+ + ); }; 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 ( +

+ + Moodle Kurs abgeschlossen +

+ ); + return ( +

+ Moodle-Kurs Benötigt + +

+ ); +}; diff --git a/apps/hub/app/(app)/events/actions.ts b/apps/hub/app/(app)/events/actions.ts new file mode 100644 index 00000000..a235d770 --- /dev/null +++ b/apps/hub/app/(app)/events/actions.ts @@ -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, + }, + }); + } +}; diff --git a/apps/hub/app/(app)/events/page.tsx b/apps/hub/app/(app)/events/page.tsx index 3515681d..7317a973 100644 --- a/apps/hub/app/(app)/events/page.tsx +++ b/apps/hub/app/(app)/events/page.tsx @@ -1,27 +1,44 @@ -import { getServerSession } from '../../api/auth/[...nextauth]/auth'; -import { PrismaClient } from '@repo/db'; -import { PilotKurs, KursItem } from './_components/item'; -import { RocketIcon } from '@radix-ui/react-icons'; +import { getServerSession } from "../../api/auth/[...nextauth]/auth"; +import { PrismaClient } from "@repo/db"; +import { ObligatedEvent, KursItem } from "./_components/item"; +import { RocketIcon } from "@radix-ui/react-icons"; export default async () => { - const prisma = new PrismaClient(); - const session = await getServerSession(); - if (!session) return null; - const user = session.user; - const events = await prisma.event.findMany(); - if (!user) return null; + const prisma = new PrismaClient(); + const session = await getServerSession(); + if (!session) return null; + const user = await prisma.user.findUnique({ + where: { + id: session.user.id, + }, + }); + if (!user) return null; - return ( -
-
-

- Events & Kurse -

-
- - {events.map((event) => ( - - ))} -
- ); + const events = await prisma.event.findMany({ + include: { + appointments: true, + participants: { + where: { + userId: user.id, + }, + }, + }, + }); + + return ( +
+
+

+ Events & Kurse +

+
+ + {events.map((event) => { + if (event.type === "OBLIGATED_COURSE") + return ; + if (event.type === "COURSE") + return ; + })} +
+ ); }; diff --git a/apps/hub/app/_components/ui/Select.tsx b/apps/hub/app/_components/ui/Select.tsx index c05fc9f7..8ffe08bb 100644 --- a/apps/hub/app/_components/ui/Select.tsx +++ b/apps/hub/app/_components/ui/Select.tsx @@ -1,62 +1,103 @@ -'use client'; +"use client"; import { - FieldValues, - Path, - RegisterOptions, - UseFormReturn, -} from 'react-hook-form'; -import SelectTemplate, { Props as SelectTemplateProps } from 'react-select'; -import { cn } from '../../../helper/cn'; -import dynamic from 'next/dynamic'; + FieldValues, + Path, + RegisterOptions, + UseFormReturn, +} from "react-hook-form"; +import SelectTemplate, { + Props as SelectTemplateProps, + StylesConfig, +} from "react-select"; +import { cn } from "../../../helper/cn"; +import dynamic from "next/dynamic"; interface SelectProps { - name: Path; - form: UseFormReturn; - formOptions?: RegisterOptions; - label?: string; - placeholder?: string; - className?: string; + name: Path; + form: UseFormReturn; + formOptions?: RegisterOptions; + label?: string; + placeholder?: string; + className?: string; } +const customStyles: StylesConfig = { + 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 = ({ - name, - label = name, - placeholder = label, - form, - formOptions, - className, - ...inputProps + name, + label = name, + placeholder = label, + form, + formOptions, + className, + ...inputProps }: SelectProps) => { - return ( -
- - {label} - - { - if ('value' in newValue) { - form.setValue(name, newValue.value); - } else { - form.setValue(name, newValue); - } - form.trigger(name); - }} - className={cn('w-full placeholder:text-neutral-600', className)} - placeholder={placeholder} - {...inputProps} - /> - {form.formState.errors[name] && ( -

- {form.formState.errors[name].message as string} -

- )} -
- ); + return ( +
+ + {label} + + { + if (Array.isArray(newValue)) { + form.setValue(name, newValue.map((v: any) => v.value) as any); + } else { + form.setValue(name, newValue.value); + } + form.trigger(name); + }} + styles={customStyles} + className={cn("w-full placeholder:text-neutral-600", className)} + placeholder={placeholder} + {...inputProps} + /> + {form.formState.errors[name]?.message && ( +

+ {form.formState.errors[name].message as string} +

+ )} +
+ ); }; const SelectWrapper = (props: any) => ; export const Select = dynamic(() => Promise.resolve(SelectWrapper), { - ssr: false, + ssr: false, }); diff --git a/apps/hub/app/api/user/route.ts b/apps/hub/app/api/user/route.ts index fe10d5b9..63b57d72 100644 --- a/apps/hub/app/api/user/route.ts +++ b/apps/hub/app/api/user/route.ts @@ -1,26 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; -import { getServerSession } from "../auth/[...nextauth]/auth"; import { prisma } from "@repo/db"; -import { generateToken } from "../../(auth)/oauth/_components/action"; -import { decode, verify } from "jsonwebtoken"; - -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(); -} +import { verify } from "jsonwebtoken"; +import { getMoodleUserById } from "../../../helper/moodle"; +import { inscribeToMoodleCourse } from "../../(app)/events/actions"; export const GET = async (req: NextRequest) => { // 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, }, }); + 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({ ...user, diff --git a/apps/hub/helper/event.ts b/apps/hub/helper/event.ts new file mode 100644 index 00000000..11537fd7 --- /dev/null +++ b/apps/hub/helper/event.ts @@ -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; +}; diff --git a/apps/hub/helper/moodle.ts b/apps/hub/helper/moodle.ts new file mode 100644 index 00000000..14e5a523 --- /dev/null +++ b/apps/hub/helper/moodle.ts @@ -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 + ); +}; diff --git a/package-lock.json b/package-lock.json index de08a270..d4bbf257 100644 --- a/package-lock.json +++ b/package-lock.json @@ -136,7 +136,8 @@ "apps/hub-server": { "dependencies": { "axios": "^1.7.9", - "cron": "^4.1.0" + "cron": "^4.1.0", + "dotenv": "^16.4.7" }, "devDependencies": { "@repo/db": "*", @@ -156,6 +157,18 @@ "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": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", diff --git a/packages/database/prisma/schema/event.prisma b/packages/database/prisma/schema/event.prisma index a3953bd3..1cc2487c 100644 --- a/packages/database/prisma/schema/event.prisma +++ b/packages/database/prisma/schema/event.prisma @@ -1,13 +1,7 @@ -enum PARTICIPANT_STATUS { - WAITING_FOR_ENTRY_TEST - ENTRY_TEST_FAILED - READY_FOR_EVENT - PARTICIPATED - WAITING_FOR_EXIT_TEST - EXIT_TEST_FAILED - WAITING_FOR_PERMISISONS - FINISHED - WAVED +enum EVENT_TYPE { + COURSE + OBLIGATED_COURSE + EVENT } model EventAppointment { @@ -25,8 +19,9 @@ model EventAppointment { model Participant { id Int @id @default(autoincrement()) userId String @map(name: "user_id") - starterMoodleCurseCompleted Boolean @default(false) finisherMoodleCurseCompleted Boolean @default(false) + attended Boolean @default(false) + finished Boolean @default(false) eventAppointmentId Int? statusLog Json[] eventId Int @@ -37,18 +32,18 @@ model Participant { } model Event { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) name String description String - discordRoleId String? @default("") - hasPresenceEvents Boolean @default(false) - maxParticipants Int? @default(0) - starterMoodleCourseId String? @default("") - finisherMoodleCourseId String? @default("") - finishedBadges String[] @default([]) - requiredBadges String[] @default([]) - finishedPermissions String[] @default([]) - hidden Boolean @default(true) + type EVENT_TYPE @default(EVENT) + discordRoleId String? @default("") + hasPresenceEvents Boolean @default(false) + maxParticipants Int? @default(0) + finisherMoodleCourseId String? @default("") + finishedBadges BADGES[] @default([]) + requiredBadges BADGES[] @default([]) + finishedPermissions PERMISSION[] @default([]) + hidden Boolean @default(true) // relations: participants Participant[]