From 48926233d5ca84577b1564e93196e7b97975a456 Mon Sep 17 00:00:00 2001 From: Elmar Kresse Date: Sat, 22 Nov 2025 16:45:38 +0100 Subject: [PATCH 1/6] feat: Add frontend Dockerfile for containerized builds and update reverse proxy to use latest Nginx image. --- docker-compose.yml | 2 +- frontend/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 968f180..f2bb6b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,7 +50,7 @@ services: - "8000:8000" rproxy: - image: docker.io/bitnami/nginx:1.28 + image: docker.io/bitnami/nginx:latest volumes: - ./reverseproxy.local.conf:/opt/bitnami/nginx/conf/nginx.conf depends_on: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 5cdcaa3..3aaa1dd 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -41,7 +41,7 @@ COPY . ./ # production stage # https://hub.docker.com/r/bitnami/nginx -> always run as non-root user -FROM docker.io/bitnami/nginx:1.28 AS prod +FROM docker.io/bitnami/nginx:latest AS prod # copy build files from build container COPY ./nginx.conf /opt/bitnami/nginx/conf/nginx.conf From 34ad90d50dcbeac87261810cddfa56561860901b Mon Sep 17 00:00:00 2001 From: Elmar Kresse Date: Sat, 22 Nov 2025 20:20:00 +0100 Subject: [PATCH 2/6] feat: Add new data-manager service for professor and module management and a new frontend application with i18n and a professor dashboard. --- docker-compose.yml | 7 +- frontend/Dockerfile | 3 + frontend/package-lock.json | 7 + frontend/package.json | 1 + frontend/src/components/MenuBar.vue | 129 +++++--- frontend/src/i18n/translations/de.json | 16 +- frontend/src/i18n/translations/en.json | 16 +- frontend/src/router/index.ts | 8 + frontend/src/service/pocketbase.ts | 27 ++ .../src/view/professor/ProfessorDashboard.vue | 233 ++++++++++++++ frontend/vite.config.ts | 1 + reverseproxy.local.conf | 4 +- services/data-manager/main.go | 14 +- .../migrations/1745249436_enable_oauth2.go | 32 ++ services/data-manager/model/moduleModel.go | 13 +- .../model/serviceModel/serviceModel.go | 11 +- services/data-manager/service/addRoute.go | 2 + .../service/professor/professorService.go | 284 ++++++++++++++++++ .../data-manager/service/professor/routes.go | 29 ++ 19 files changed, 769 insertions(+), 68 deletions(-) create mode 100644 frontend/src/service/pocketbase.ts create mode 100644 frontend/src/view/professor/ProfessorDashboard.vue create mode 100644 services/data-manager/migrations/1745249436_enable_oauth2.go create mode 100644 services/data-manager/service/professor/professorService.go create mode 100644 services/data-manager/service/professor/routes.go diff --git a/docker-compose.yml b/docker-compose.yml index f2bb6b2..2df09a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,10 +42,13 @@ services: build: dockerfile: Dockerfile context: ./frontend - target: prod + target: dev args: - COMMIT_HASH=${COMMIT_HASH} # open port 8000 + volumes: + - ./frontend/src/:/app/src + - ./frontend/public/:/app/public ports: - "8000:8000" @@ -57,7 +60,7 @@ services: - htwkalender-data-manager - htwkalender-frontend ports: - - "8080:8080" + - "80:80" volumes: pb_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 3aaa1dd..f8a2c2a 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -39,6 +39,9 @@ RUN echo "COMMIT_HASH=$COMMIT_HASH" >> .env.commit RUN npm install COPY . ./ +# execute the npm run dev command +CMD ["npm", "run", "dev"] + # production stage # https://hub.docker.com/r/bitnami/nginx -> always run as non-root user FROM docker.io/bitnami/nginx:latest AS prod diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ce1e327..9951268 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "@vueuse/core": "^13.0.0", "country-flag-emoji-polyfill": "^0.1.8", "pinia": "^3.0.1", + "pocketbase": "^0.26.3", "primeflex": "^3.3.1", "primeicons": "^7.0.0", "primevue": "^3.53.1", @@ -5593,6 +5594,12 @@ "@vue/devtools-kit": "^7.7.2" } }, + "node_modules/pocketbase": { + "version": "0.26.3", + "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.3.tgz", + "integrity": "sha512-5deUKRoEczpxxuHzwr6/DHVmgbggxylEVig8CKN+MjvtYxPUqX/C6puU0yaR2yhTi8zrh7J9s7Ty+qBGwVzWOQ==", + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6c62dd3..094e868 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "@vueuse/core": "^13.0.0", "country-flag-emoji-polyfill": "^0.1.8", "pinia": "^3.0.1", + "pocketbase": "^0.26.3", "primeflex": "^3.3.1", "primeicons": "^7.0.0", "primevue": "^3.53.1", diff --git a/frontend/src/components/MenuBar.vue b/frontend/src/components/MenuBar.vue index 6e4649d..468d192 100644 --- a/frontend/src/components/MenuBar.vue +++ b/frontend/src/components/MenuBar.vue @@ -17,61 +17,86 @@ along with this program. If not, see . --> + + + + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 719f22f..32ab09b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -79,6 +79,7 @@ export default defineConfig({ watch: { usePolling: true, }, + allowedHosts: ["localhost", "127.0.0.1", "::1", "cal.htwk-leipzig.de", "htwkalender-frontend"], proxy: { "/api": { target: "http://localhost:8090/api", diff --git a/reverseproxy.local.conf b/reverseproxy.local.conf index bb05c34..42a12cf 100644 --- a/reverseproxy.local.conf +++ b/reverseproxy.local.conf @@ -25,8 +25,8 @@ http { limit_req_zone $ratelimit_key zone=createFeed:10m rate=1r/m; server { - listen 8080; - listen [::]:8080; + listen 80; + listen [::]:80; http2 on; location /api/feed { diff --git a/services/data-manager/main.go b/services/data-manager/main.go index a5fbcf6..afdfc7d 100644 --- a/services/data-manager/main.go +++ b/services/data-manager/main.go @@ -17,27 +17,31 @@ package main import ( - "github.com/pocketbase/pocketbase" - "github.com/pocketbase/pocketbase/plugins/migratecmd" _ "htwkalender/data-manager/migrations" "htwkalender/data-manager/model/serviceModel" "htwkalender/data-manager/service" "htwkalender/data-manager/service/events" "htwkalender/data-manager/service/grpc" + "htwkalender/data-manager/service/professor" "log/slog" "os" "strings" + + "github.com/pocketbase/pocketbase" + "github.com/pocketbase/pocketbase/plugins/migratecmd" ) func setupApp() *pocketbase.PocketBase { app := pocketbase.New() courseService := events.NewPocketBaseCourseService(app) eventService := events.NewPocketBaseEventService(app) + professorService := professor.NewProfessorService(app) services := serviceModel.Service{ - CourseService: courseService, - EventService: eventService, - App: app, + CourseService: courseService, + EventService: eventService, + ProfessorService: professorService, + App: app, } // loosely check if it was executed using "go run" diff --git a/services/data-manager/migrations/1745249436_enable_oauth2.go b/services/data-manager/migrations/1745249436_enable_oauth2.go new file mode 100644 index 0000000..98a0a62 --- /dev/null +++ b/services/data-manager/migrations/1745249436_enable_oauth2.go @@ -0,0 +1,32 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("users") + if err != nil { + return err + } + + // Enable OAuth2 + collection.OAuth2.Enabled = true + + // Optional: Map fields if necessary, for now just enabling it + // collection.OAuth2.MappedFields.Name = "name" + + return app.Save(collection) + }, func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("users") + if err != nil { + return err + } + + collection.OAuth2.Enabled = false + + return app.Save(collection) + }) +} diff --git a/services/data-manager/model/moduleModel.go b/services/data-manager/model/moduleModel.go index 1a9e3e9..b8e0ad6 100644 --- a/services/data-manager/model/moduleModel.go +++ b/services/data-manager/model/moduleModel.go @@ -30,12 +30,13 @@ func (m *Module) SetName(name string) { } type ModuleDTO struct { - UUID string `json:"uuid" db:"uuid"` - Name string `json:"name" db:"Name"` - Prof string `json:"prof" db:"Prof"` - Course string `json:"course" db:"course"` - Semester string `json:"semester" db:"semester"` - EventType string `db:"EventType" json:"eventType"` + UUID string `json:"uuid" db:"uuid"` + Name string `json:"name" db:"Name"` + Prof string `json:"prof" db:"Prof"` + Course string `json:"course" db:"course"` + Semester string `json:"semester" db:"semester"` + EventType string `db:"EventType" json:"eventType"` + ConfidenceScore float64 `json:"confidenceScore,omitempty"` } func (m *ModuleDTO) GetName() string { diff --git a/services/data-manager/model/serviceModel/serviceModel.go b/services/data-manager/model/serviceModel/serviceModel.go index 8e2fe69..bcc2f75 100644 --- a/services/data-manager/model/serviceModel/serviceModel.go +++ b/services/data-manager/model/serviceModel/serviceModel.go @@ -1,12 +1,15 @@ package serviceModel import ( - "github.com/pocketbase/pocketbase" "htwkalender/data-manager/service/events" + "htwkalender/data-manager/service/professor" + + "github.com/pocketbase/pocketbase" ) type Service struct { - App *pocketbase.PocketBase - EventService events.EventService - CourseService events.CourseService + App *pocketbase.PocketBase + EventService events.EventService + CourseService events.CourseService + ProfessorService *professor.ProfessorService } diff --git a/services/data-manager/service/addRoute.go b/services/data-manager/service/addRoute.go index 898f712..3a1098e 100644 --- a/services/data-manager/service/addRoute.go +++ b/services/data-manager/service/addRoute.go @@ -23,6 +23,7 @@ import ( v1 "htwkalender/data-manager/service/fetch/v1" v2 "htwkalender/data-manager/service/fetch/v2" "htwkalender/data-manager/service/functions/time" + "htwkalender/data-manager/service/professor" "htwkalender/data-manager/service/room" "log/slog" "net/http" @@ -231,6 +232,7 @@ func AddRoutes(services serviceModel.Service) { }).Bind(apis.RequireSuperuserAuth()) addFeedRoutes(se, services.App) + professor.RegisterRoutes(se, services.ProfessorService) return se.Next() }) diff --git a/services/data-manager/service/professor/professorService.go b/services/data-manager/service/professor/professorService.go new file mode 100644 index 0000000..a4f6325 --- /dev/null +++ b/services/data-manager/service/professor/professorService.go @@ -0,0 +1,284 @@ +package professor + +import ( + "fmt" + "htwkalender/data-manager/model" + "log/slog" + "strings" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase" +) + +type ProfessorService struct { + app *pocketbase.PocketBase +} + +func NewProfessorService(app *pocketbase.PocketBase) *ProfessorService { + return &ProfessorService{app: app} +} + +func (s *ProfessorService) GetModulesForProfessor(email string) ([]model.ModuleDTO, error) { + // Extract name from email + // Format: firstname.lastname@htwk-leipzig.de + parts := strings.Split(email, "@") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid email format") + } + + nameParts := strings.Split(parts[0], ".") + if len(nameParts) < 2 { + slog.Warn("Email does not contain dot separator", "email", email) + return []model.ModuleDTO{}, nil + } + + // Extract first and last name + firstName := nameParts[0] + lastName := nameParts[len(nameParts)-1] + + // Capitalize first letter + if len(firstName) > 0 { + firstName = strings.ToUpper(firstName[:1]) + firstName[1:] + } + if len(lastName) > 0 { + lastName = strings.ToUpper(lastName[:1]) + lastName[1:] + } + + slog.Info("Searching for modules for professor", "firstName", firstName, "lastName", lastName, "email", email) + + // First, get all distinct modules with their professors + type EventProf struct { + Name string `db:"Name" json:"name"` + EventType string `db:"EventType" json:"eventType"` + Prof string `db:"Prof" json:"prof"` + Course string `db:"course" json:"course"` + Semester string `db:"semester" json:"semester"` + UUID string `db:"uuid" json:"uuid"` + } + + var allEvents []EventProf + err := s.app.DB(). + Select("Name", "EventType", "Prof", "course", "semester", "uuid"). + From("events"). + Where(dbx.NewExp("Prof != ''")). + GroupBy("Name", "course", "Prof"). + Distinct(true). + All(&allEvents) + + if err != nil { + return nil, err + } + + // Filter events by matching professor name and calculate confidence scores + var modules []model.ModuleDTO + seenModules := make(map[string]bool) // key: Name+Course to avoid duplicates + + for _, event := range allEvents { + score := calculateConfidenceScore(event.Prof, firstName, lastName) + if score > 0 { // Include all modules with any match + key := event.Name + "|" + event.Course + if !seenModules[key] { + modules = append(modules, model.ModuleDTO{ + Name: event.Name, + EventType: event.EventType, + Prof: event.Prof, + Course: event.Course, + Semester: event.Semester, + UUID: event.UUID, + ConfidenceScore: score, + }) + seenModules[key] = true + } + } + } + + slog.Info("Found modules for professor", "count", len(modules), "lastName", lastName) + return modules, nil +} + +// calculateConfidenceScore returns a score from 0.0 to 1.0 indicating how confident we are +// that this professor string matches the given first and last name +// 1.0 = perfect match (both first and last name exact) +// 0.7-0.9 = good match (last name exact, first name fuzzy or present) +// 0.4-0.6 = possible match (last name fuzzy or partial) +// 0.1-0.3 = weak match (last name substring) +// 0.0 = no match +func calculateConfidenceScore(profString, firstName, lastName string) float64 { + // Normalize the professor string: remove common titles and split into words + profString = strings.ToLower(profString) + + // Remove common titles + titles := []string{"prof.", "dr.", "arch.", "ing.", "dipl.", "m.sc.", "b.sc.", "ph.d."} + for _, title := range titles { + profString = strings.ReplaceAll(profString, title, "") + } + + // Split by spaces, hyphens, and other separators + words := strings.FieldsFunc(profString, func(r rune) bool { + return r == ' ' || r == '-' || r == ',' || r == '.' + }) + + // Normalize firstName and lastName + firstNameLower := strings.ToLower(firstName) + lastNameLower := strings.ToLower(lastName) + + lastNameExact := false + lastNameFuzzy := false + lastNameSubstring := false + firstNameExact := false + firstNameFuzzy := false + + for _, word := range words { + word = strings.TrimSpace(word) + if word == "" { + continue + } + + // Check last name + if word == lastNameLower { + lastNameExact = true + } else if levenshteinDistance(word, lastNameLower) <= 1 && len(lastNameLower) > 3 { + lastNameFuzzy = true + } else if strings.Contains(word, lastNameLower) || strings.Contains(lastNameLower, word) { + lastNameSubstring = true + } + + // Check first name + if word == firstNameLower { + firstNameExact = true + } else if levenshteinDistance(word, firstNameLower) <= 1 && len(firstNameLower) > 3 { + firstNameFuzzy = true + } + } + + // Calculate confidence score based on matches + score := 0.0 + + if lastNameExact { + if firstNameExact { + score = 1.0 // Perfect match + } else if firstNameFuzzy { + score = 0.9 // Excellent match + } else { + score = 0.8 // Good match (last name exact, no first name match) + } + } else if lastNameFuzzy { + if firstNameExact || firstNameFuzzy { + score = 0.6 // Decent match (fuzzy last name but first name matches) + } else { + score = 0.5 // Medium match (fuzzy last name, no first name) + } + } else if lastNameSubstring { + score = 0.2 // Weak match (substring only) + } + + return score +} + +// matchesProfessor is deprecated, use calculateConfidenceScore instead +func matchesProfessor(profString, firstName, lastName string) bool { + // Normalize the professor string: remove common titles and split into words + profString = strings.ToLower(profString) + + // Remove common titles + titles := []string{"prof.", "dr.", "arch.", "ing.", "dipl.", "m.sc.", "b.sc.", "ph.d."} + for _, title := range titles { + profString = strings.ReplaceAll(profString, title, "") + } + + // Split by spaces, hyphens, and other separators + words := strings.FieldsFunc(profString, func(r rune) bool { + return r == ' ' || r == '-' || r == ',' || r == '.' + }) + + // Normalize firstName and lastName + firstNameLower := strings.ToLower(firstName) + lastNameLower := strings.ToLower(lastName) + + lastNameFound := false + firstNameFound := false + + for _, word := range words { + word = strings.TrimSpace(word) + if word == "" { + continue + } + + // Exact match for last name + if word == lastNameLower { + lastNameFound = true + } + + // Exact match for first name (optional, but increases confidence) + if word == firstNameLower { + firstNameFound = true + } + + // Also check Levenshtein distance for typos + if !lastNameFound && levenshteinDistance(word, lastNameLower) <= 1 { + lastNameFound = true + } + if !firstNameFound && levenshteinDistance(word, firstNameLower) <= 1 { + firstNameFound = true + } + } + + // Match if last name is found (first name is optional for additional confidence) + // We require at least the last name to match + return lastNameFound +} + +// levenshteinDistance calculates the Levenshtein distance between two strings +func levenshteinDistance(s1, s2 string) int { + if len(s1) == 0 { + return len(s2) + } + if len(s2) == 0 { + return len(s1) + } + + // Create a 2D array for dynamic programming + d := make([][]int, len(s1)+1) + for i := range d { + d[i] = make([]int, len(s2)+1) + } + + // Initialize first column and row + for i := 0; i <= len(s1); i++ { + d[i][0] = i + } + for j := 0; j <= len(s2); j++ { + d[0][j] = j + } + + // Fill the matrix + for i := 1; i <= len(s1); i++ { + for j := 1; j <= len(s2); j++ { + cost := 0 + if s1[i-1] != s2[j-1] { + cost = 1 + } + + d[i][j] = min( + d[i-1][j]+1, // deletion + d[i][j-1]+1, // insertion + d[i-1][j-1]+cost, // substitution + ) + } + } + + return d[len(s1)][len(s2)] +} + +func min(a, b, c int) int { + if a < b { + if a < c { + return a + } + return c + } + if b < c { + return b + } + return c +} diff --git a/services/data-manager/service/professor/routes.go b/services/data-manager/service/professor/routes.go new file mode 100644 index 0000000..0494bf8 --- /dev/null +++ b/services/data-manager/service/professor/routes.go @@ -0,0 +1,29 @@ +package professor + +import ( + "net/http" + + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" +) + +func RegisterRoutes(se *core.ServeEvent, service *ProfessorService) { + se.Router.GET("/api/professor/modules", func(e *core.RequestEvent) error { + record := e.Auth + if record == nil { + return apis.NewForbiddenError("Only authenticated users can access this endpoint", nil) + } + + email := record.GetString("email") + if email == "" { + return apis.NewBadRequestError("User has no email", nil) + } + + modules, err := service.GetModulesForProfessor(email) + if err != nil { + return apis.NewBadRequestError("Failed to fetch modules", err) + } + + return e.JSON(http.StatusOK, modules) + }).Bind(apis.RequireAuth()) +} From ac6e1fe0dde5b02f90f4758fa10935adffaee6db Mon Sep 17 00:00:00 2001 From: Elmar Kresse Date: Sat, 22 Nov 2025 21:20:41 +0100 Subject: [PATCH 3/6] feat: introduce feed management for individual and professor modules across frontend and backend services. --- frontend/src/api/createFeed.ts | 31 + frontend/src/store/moduleStore.ts | 4 + frontend/src/view/create/RenameModules.vue | 13 +- .../src/view/professor/ProfessorDashboard.vue | 1 + services/common/genproto/modules/feeds.pb.go | 56 +- services/common/professor/matching.go | 175 ++++ .../1763841597_collections_snapshot.go | 791 ++++++++++++++++++ services/data-manager/model/feedModel.go | 2 + services/data-manager/service/db/dbFeeds.go | 32 +- .../data-manager/service/grpc/feedService.go | 17 +- services/data-manager/service/grpc/mapper.go | 2 + services/data-manager/service/ical/ical.go | 20 +- .../service/professor/professorService.go | 219 +---- .../data-manager/service/professor/routes.go | 34 + services/ical/model/feedModel.go | 3 + services/ical/model/icalModel.go | 3 + services/ical/service/connector/grpc/feeds.go | 8 +- services/ical/service/ical/ical.go | 26 + services/protobuf/feeds.proto | 3 + .../htwkalender/common/modules/feeds.pb.go | 350 ++++++++ .../common/modules/feeds_grpc.pb.go | 105 +++ 21 files changed, 1655 insertions(+), 240 deletions(-) create mode 100644 services/common/professor/matching.go create mode 100644 services/data-manager/migrations/1763841597_collections_snapshot.go create mode 100644 services/protobuf/htwkalender/common/modules/feeds.pb.go create mode 100644 services/protobuf/htwkalender/common/modules/feeds_grpc.pb.go diff --git a/frontend/src/api/createFeed.ts b/frontend/src/api/createFeed.ts index ce2ba84..414c6d9 100644 --- a/frontend/src/api/createFeed.ts +++ b/frontend/src/api/createFeed.ts @@ -44,6 +44,37 @@ export async function createIndividualFeed(modules: Module[]): Promise { } } +export async function createProfessorFeed(modules: Module[]): Promise { + if (import.meta.env.SSR) { + return ""; + } + try { + // Dynamic import to avoid circular dependencies or issues if pb is not initialized + const { pb } = await import("../service/pocketbase"); + + const response = await fetch("/api/professor/feed", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": pb.authStore.token, + }, + body: JSON.stringify(modules), + }); + + if ( + response.status === 429 || + response.status === 500 || + response.status != 200 + ) { + return Promise.reject(response.statusText); + } + + return await response.json(); + } catch (error) { + return Promise.reject(error); + } +} + interface FeedResponse { modules: FeedModule[]; collectionId: string; diff --git a/frontend/src/store/moduleStore.ts b/frontend/src/store/moduleStore.ts index 5ec39a6..7a8ff10 100644 --- a/frontend/src/store/moduleStore.ts +++ b/frontend/src/store/moduleStore.ts @@ -20,6 +20,7 @@ import { defineStore } from "pinia"; const moduleStore = defineStore("moduleStore", { state: () => ({ modules: new Map(), + isProfessorFeed: false, }), actions: { addModule(module: Module) { @@ -55,6 +56,9 @@ const moduleStore = defineStore("moduleStore", { containsModule(module: Module): boolean { return this.modules.has(module.uuid); }, + setProfessorFeed(isProf: boolean) { + this.isProfessorFeed = isProf; + }, }, }); diff --git a/frontend/src/view/create/RenameModules.vue b/frontend/src/view/create/RenameModules.vue index a630912..d0b7c0b 100644 --- a/frontend/src/view/create/RenameModules.vue +++ b/frontend/src/view/create/RenameModules.vue @@ -18,7 +18,7 @@ along with this program. If not, see .