feat: Add new data-manager service for professor and module management and a new frontend application with i18n and a professor dashboard.

This commit is contained in:
Elmar Kresse
2025-11-22 20:20:00 +01:00
parent 48926233d5
commit 34ad90d50d
19 changed files with 769 additions and 68 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -17,61 +17,86 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts" setup>
import { computed, ref } from "vue";
import { computed, ref, onMounted, onUnmounted } from "vue";
import { useI18n } from "vue-i18n";
import LocaleSwitcher from "./LocaleSwitcher.vue";
import DarkModeSwitcher from "./DarkModeSwitcher.vue";
import { useRoute } from "vue-router";
import { pb, login, logout } from "../service/pocketbase";
const { t } = useI18n({ useScope: "global" });
const route = useRoute();
const isDark = ref(true);
const currentUser = ref(pb.authStore.model);
const items = computed(() => [
{
label: t("createCalendar"),
icon: "pi pi-fw pi-plus",
route: "/",
},
{
label: t("editCalendar"),
icon: "pi pi-fw pi-pencil",
route: "/edit",
},
{
label: t("rooms"),
icon: "pi pi-fw pi-angle-down",
info: "rooms",
items: [
{
label: t("roomFinderPage.roomSchedule"),
icon: "pi pi-fw pi-hourglass",
route: "/rooms/occupancy",
},
{
label: t("freeRooms.freeRooms"),
icon: "pi pi-fw pi-calendar",
route: "/rooms/free",
},
],
},
{
label: t("faq"),
icon: "pi pi-fw pi-book",
route: "/faq",
},
{
label: t("imprint"),
icon: "pi pi-fw pi-id-card",
url: "https://www.htwk-leipzig.de/hochschule/kontakt/impressum/",
},
{
label: t("privacy"),
icon: "pi pi-fw pi-exclamation-triangle",
url: "https://www.htwk-leipzig.de/hochschule/kontakt/datenschutzerklaerung/",
},
]);
onMounted(() => {
pb.authStore.onChange((_, model) => {
currentUser.value = model;
});
});
onUnmounted(() => {
pb.authStore.onChange(() => {});
});
const items = computed(() => {
const menuItems = [
{
label: t("createCalendar"),
icon: "pi pi-fw pi-plus",
route: "/",
},
{
label: t("editCalendar"),
icon: "pi pi-fw pi-pencil",
route: "/edit",
},
{
label: t("rooms"),
icon: "pi pi-fw pi-angle-down",
info: "rooms",
items: [
{
label: t("roomFinderPage.roomSchedule"),
icon: "pi pi-fw pi-hourglass",
route: "/rooms/occupancy",
},
{
label: t("freeRooms.freeRooms"),
icon: "pi pi-fw pi-calendar",
route: "/rooms/free",
},
],
},
{
label: t("faq"),
icon: "pi pi-fw pi-book",
route: "/faq",
},
{
label: t("imprint"),
icon: "pi pi-fw pi-id-card",
url: "https://www.htwk-leipzig.de/hochschule/kontakt/impressum/",
},
{
label: t("privacy"),
icon: "pi pi-fw pi-exclamation-triangle",
url: "https://www.htwk-leipzig.de/hochschule/kontakt/datenschutzerklaerung/",
},
];
if (currentUser.value) {
menuItems.push({
label: "Professor Dashboard",
icon: "pi pi-fw pi-user",
route: "/professor/dashboard",
});
}
return menuItems;
});
function handleDarkModeToggled(isDarkVar: boolean) {
// Do something with isDark value
@@ -143,6 +168,20 @@ function handleDarkModeToggled(isDarkVar: boolean) {
@dark-mode-toggled="handleDarkModeToggled"
></DarkModeSwitcher>
<LocaleSwitcher></LocaleSwitcher>
<Button
v-if="!currentUser"
label="Login"
icon="pi pi-sign-in"
class="p-button-text"
@click="login"
/>
<Button
v-else
label="Logout"
icon="pi pi-sign-out"
class="p-button-text"
@click="logout"
/>
</div>
</template>
</Menubar>

View File

@@ -273,6 +273,18 @@
"notFound": "Nicht gefunden, wonach du suchst?",
"contact": "Kontakt aufnehmen"
},
"professor": {
"dashboard": "Dashboard",
"intro": "Willkommen im HTWKalender Professor, im ersten Schritt wählen Sie Ihre Module aus. Die empfohlenen Module werden automatisch ausgewählt. Weitere Module können manuell angewählt werden.",
"nextStep": "Weiter",
"name": "Name",
"professor": "Professor",
"course": "Modul",
"semester": "Semester",
"match": "Abgleich",
"searchByName": "Filtern nach Name",
"searchByProfessor": "Filtern nach Professor"
},
"footer": {
"commitInfo": {
"tooltip": "Version",
@@ -280,9 +292,9 @@
"description": "Zeigt die Commit-ID der verwendeten Frontend-Version des HTWKalenders an. Für Debbugger und Entwickler.",
"hash": "Commit-Hash",
"component": "Komponente",
"components":{
"components": {
"frontend": "Frontend"
}
}
}
}
}

View File

@@ -273,6 +273,18 @@
"notFound": "Not finding what you're looking for?",
"contact": "Get in touch"
},
"professor": {
"dashboard": "Dashboard",
"intro": "Welcome to the HTWKalender Professor, in the first step you select your modules. The recommended modules are automatically selected. Additional modules can be selected manually.",
"nextStep": "Next",
"name": "name",
"professor": "professor",
"course": "course",
"semester": "semester",
"match": "match",
"searchByName": "filter by name",
"searchByProfessor": "filter by professor"
},
"footer": {
"commitInfo": {
"tooltip": "application version",
@@ -280,9 +292,9 @@
"description": "Shows the commit id of the deployed frontend version of the HTWKalender. For debuggers and developers.",
"hash": "commit hash",
"component": "component",
"components":{
"components": {
"frontend": "frontend"
}
}
}
}
}

View File

@@ -142,6 +142,14 @@ const routes: RouterOptions = {
label: "createCalendar",
},
},
{
path: "/professor/dashboard",
name: "professor-dashboard",
component: () => import("../view/professor/ProfessorDashboard.vue"),
meta: {
label: "professor.dashboard",
},
},
],
};

View File

@@ -0,0 +1,27 @@
import PocketBase from 'pocketbase';
// const domain = "cal.htwk-leipzig.de";
// const baseUri = "https://" + domain;
// For development, we might want to use the local backend
// const backendUrl = import.meta.env.DEV ? 'http://127.0.0.1:8090' : baseUri;
// But since we are running in docker, the frontend might be accessing the backend via a different URL or proxy.
// The existing code uses relative paths for API calls which go through the reverse proxy.
// PocketBase SDK needs a full URL or it defaults to /.
// If we are serving frontend from the same domain as backend (via proxy), / is fine.
// However, the proxy config shows backend at /api/ and pocketbase at /_/?
// Let's check reverseproxy.conf
export const pb = new PocketBase("/");
// Note: OAuth2 redirect URL should be configured in Keycloak/OIDC provider as:
// Development: http://localhost/callback
// Production: https://cal.htwk-leipzig.de/callback
export const login = async () => {
await pb.collection('users').authWithOAuth2({ provider: 'oidc' });
}
export const logout = () => {
pb.authStore.clear();
}

View File

@@ -0,0 +1,233 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { pb } from "../../service/pocketbase";
import { Module } from "../../model/module";
import { useI18n } from "vue-i18n";
import Button from "primevue/button";
import DataTable from "primevue/datatable";
import Column from "primevue/column";
import Skeleton from "primevue/skeleton";
import InputText from "primevue/inputtext";
import { FilterMatchMode } from "primevue/api";
import { useRouter } from "vue-router";
const { t } = useI18n({ useScope: "global" });
const router = useRouter();
const modules = ref<Module[]>(new Array(10));
const selectedModules = ref<Module[]>([]);
const loading = ref(true);
const filters = ref({
name: {
value: null,
matchMode: FilterMatchMode.CONTAINS,
},
prof: {
value: null,
matchMode: FilterMatchMode.CONTAINS,
},
});
onMounted(async () => {
try {
// Check if user is authenticated
if (!pb.authStore.isValid || !pb.authStore.token) {
console.error("User is not authenticated");
router.push("/");
return;
}
console.log("Auth token:", pb.authStore.token); // Debug
// Make request with explicit Authorization header
const response = await fetch("/api/professor/modules", {
method: "GET",
headers: {
"Authorization": pb.authStore.token,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
// Convert API response to Module instances with all required fields
const moduleObjects = result.map((apiModule: any) => new Module(
apiModule.uuid,
apiModule.name,
apiModule.course,
apiModule.eventType || "",
apiModule.name, // userDefinedName defaults to module name
apiModule.prof,
apiModule.semester,
false, // reminder defaults to false
[] // events defaults to empty array
));
// Sort modules by confidence score (highest first)
// Note: confidenceScore is not part of Module class, so we need to preserve it
const sortedModules = moduleObjects.map((module: Module, index: number) => {
const originalData = result[index];
return {
...module,
confidenceScore: originalData.confidenceScore || 0
};
}).sort((a: any, b: any) => {
return (b.confidenceScore || 0) - (a.confidenceScore || 0); // Descending order
});
modules.value = sortedModules;
// Pre-select modules with confidence score >= 0.7 (good match or better)
selectedModules.value = sortedModules.filter((m: any) => (m.confidenceScore || 0) >= 0.7);
console.log("Modules loaded:", modules.value.length, "Pre-selected:", selectedModules.value.length);
} catch (error: any) {
console.error("Failed to fetch modules", error);
// If unauthorized, redirect to home
if (error.status === 401 || error.message?.includes("401")) {
pb.authStore.clear();
router.push("/");
}
} finally {
loading.value = false;
}
});
const createCalendar = () => {
// Similar to course selection, we want to proceed to rename or just view calendar
// We can store the selected modules in the store and redirect
// But wait, the store is for the "current" calendar creation session.
// If we want to use the existing flow:
// 1. Clear store? Or just add?
// Let's assume we want to start fresh or add to existing.
// For now, let's just construct the URL parameters or use the store.
// Actually, the user wants "custom calendar creation process ... selection of modules and the renaming".
// So we can redirect to /rename-modules with the selected modules pre-filled in the store.
import("../../store/moduleStore").then(({ default: useModuleStore }) => {
const store = useModuleStore();
store.removeAllModules();
selectedModules.value.forEach((m) => store.addModule(m));
router.push("/rename-modules");
});
};
</script>
<template>
<div class="card">
<h1>{{ t("professor.dashboard") }}</h1>
<p>{{ t("professor.intro") }}</p>
<DataTable
v-model:selection="selectedModules"
v-model:filters="filters"
:value="modules"
dataKey="uuid"
responsiveLayout="scroll"
paginator
:rows="10"
:rows-per-page-options="[5, 10, 20, 50]"
filter-display="row"
:show-gridlines="true"
:striped-rows="true"
>
<Column selectionMode="multiple" headerStyle="width: 3em">
<template v-if="loading" #body>
<Skeleton></Skeleton>
</template>
</Column>
<Column
field="name"
:header="t('professor.name')"
:show-filter-menu="false"
:show-clear-button="false"
>
<template #filter="{ filterModel, filterCallback }">
<InputText
v-model="filterModel.value"
type="text"
class="p-column-filter"
:placeholder="t('professor.searchByName')"
@input="filterCallback()"
/>
</template>
<template v-if="loading" #body>
<Skeleton></Skeleton>
</template>
</Column>
<Column
field="prof"
:header="t('professor.professor')"
:show-filter-menu="false"
:show-clear-button="false"
>
<template #filter="{ filterModel, filterCallback }">
<InputText
v-model="filterModel.value"
type="text"
class="p-column-filter"
:placeholder="t('professor.searchByProfessor')"
@input="filterCallback()"
/>
</template>
<template v-if="loading" #body>
<Skeleton></Skeleton>
</template>
</Column>
<Column field="course" :header="t('professor.course')">
<template v-if="loading" #body>
<Skeleton></Skeleton>
</template>
</Column>
<Column field="semester" :header="t('professor.semester')">
<template v-if="loading" #body>
<Skeleton></Skeleton>
</template>
</Column>
<Column field="confidenceScore" :header="t('professor.match')" sortable>
<template #body="slotProps">
<div v-if="loading">
<Skeleton></Skeleton>
</div>
<div v-else>
<span v-if="slotProps.data.confidenceScore >= 0.9" style="color: green; font-weight: bold;">
{{ Math.round(slotProps.data.confidenceScore * 100) }}%
</span>
<span v-else-if="slotProps.data.confidenceScore >= 0.7" style="color: #4CAF50;">
{{ Math.round(slotProps.data.confidenceScore * 100) }}%
</span>
<span v-else-if="slotProps.data.confidenceScore >= 0.5" style="color: orange;">
{{ Math.round(slotProps.data.confidenceScore * 100) }}%
</span>
<span v-else style="color: #999;">
{{ Math.round(slotProps.data.confidenceScore * 100) }}%
</span>
</div>
</template>
</Column>
</DataTable>
<div class="flex justify-content-end mt-4">
<Button
:label="t('professor.nextStep')"
icon="pi pi-arrow-right"
@click="createCalendar"
:disabled="selectedModules.length === 0"
/>
</div>
</div>
</template>
<style scoped>
.card {
padding: 2rem;
}
</style>

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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)
})
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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()
})

View File

@@ -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
}

View File

@@ -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())
}