package services import ( "context" "crypto/rand" "encoding/hex" "fmt" "time" "github.com/breakpilot/consent-service/internal/database" "github.com/breakpilot/consent-service/internal/models" "github.com/breakpilot/consent-service/internal/services/matrix" "github.com/google/uuid" ) // SchoolService handles school management operations type SchoolService struct { db *database.DB matrix *matrix.MatrixService } // NewSchoolService creates a new school service func NewSchoolService(db *database.DB, matrixService *matrix.MatrixService) *SchoolService { return &SchoolService{ db: db, matrix: matrixService, } } // ======================================== // School CRUD // ======================================== // CreateSchool creates a new school func (s *SchoolService) CreateSchool(ctx context.Context, req models.CreateSchoolRequest) (*models.School, error) { school := &models.School{ ID: uuid.New(), Name: req.Name, ShortName: req.ShortName, Type: req.Type, Address: req.Address, City: req.City, PostalCode: req.PostalCode, State: req.State, Country: "DE", Phone: req.Phone, Email: req.Email, Website: req.Website, IsActive: true, CreatedAt: time.Now(), UpdatedAt: time.Now(), } query := ` INSERT INTO schools (id, name, short_name, type, address, city, postal_code, state, country, phone, email, website, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id` err := s.db.Pool.QueryRow(ctx, query, school.ID, school.Name, school.ShortName, school.Type, school.Address, school.City, school.PostalCode, school.State, school.Country, school.Phone, school.Email, school.Website, school.IsActive, school.CreatedAt, school.UpdatedAt, ).Scan(&school.ID) if err != nil { return nil, fmt.Errorf("failed to create school: %w", err) } // Create default timetable slots for the school if err := s.createDefaultTimetableSlots(ctx, school.ID); err != nil { // Log but don't fail fmt.Printf("Warning: failed to create default timetable slots: %v\n", err) } // Create default grade scale if err := s.createDefaultGradeScale(ctx, school.ID); err != nil { fmt.Printf("Warning: failed to create default grade scale: %v\n", err) } return school, nil } // GetSchool retrieves a school by ID func (s *SchoolService) GetSchool(ctx context.Context, schoolID uuid.UUID) (*models.School, error) { query := ` SELECT id, name, short_name, type, address, city, postal_code, state, country, phone, email, website, matrix_server_name, logo_url, is_active, created_at, updated_at FROM schools WHERE id = $1` school := &models.School{} err := s.db.Pool.QueryRow(ctx, query, schoolID).Scan( &school.ID, &school.Name, &school.ShortName, &school.Type, &school.Address, &school.City, &school.PostalCode, &school.State, &school.Country, &school.Phone, &school.Email, &school.Website, &school.MatrixServerName, &school.LogoURL, &school.IsActive, &school.CreatedAt, &school.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("failed to get school: %w", err) } return school, nil } // ListSchools lists all active schools func (s *SchoolService) ListSchools(ctx context.Context) ([]models.School, error) { query := ` SELECT id, name, short_name, type, address, city, postal_code, state, country, phone, email, website, matrix_server_name, logo_url, is_active, created_at, updated_at FROM schools WHERE is_active = true ORDER BY name` rows, err := s.db.Pool.Query(ctx, query) if err != nil { return nil, fmt.Errorf("failed to list schools: %w", err) } defer rows.Close() var schools []models.School for rows.Next() { var school models.School err := rows.Scan( &school.ID, &school.Name, &school.ShortName, &school.Type, &school.Address, &school.City, &school.PostalCode, &school.State, &school.Country, &school.Phone, &school.Email, &school.Website, &school.MatrixServerName, &school.LogoURL, &school.IsActive, &school.CreatedAt, &school.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("failed to scan school: %w", err) } schools = append(schools, school) } return schools, nil } // ======================================== // School Year Management // ======================================== // CreateSchoolYear creates a new school year func (s *SchoolService) CreateSchoolYear(ctx context.Context, schoolID uuid.UUID, name string, startDate, endDate time.Time) (*models.SchoolYear, error) { schoolYear := &models.SchoolYear{ ID: uuid.New(), SchoolID: schoolID, Name: name, StartDate: startDate, EndDate: endDate, IsCurrent: false, CreatedAt: time.Now(), } query := ` INSERT INTO school_years (id, school_id, name, start_date, end_date, is_current, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id` err := s.db.Pool.QueryRow(ctx, query, schoolYear.ID, schoolYear.SchoolID, schoolYear.Name, schoolYear.StartDate, schoolYear.EndDate, schoolYear.IsCurrent, schoolYear.CreatedAt, ).Scan(&schoolYear.ID) if err != nil { return nil, fmt.Errorf("failed to create school year: %w", err) } return schoolYear, nil } // SetCurrentSchoolYear sets a school year as the current one func (s *SchoolService) SetCurrentSchoolYear(ctx context.Context, schoolID, schoolYearID uuid.UUID) error { // First, unset all current school years for this school _, err := s.db.Pool.Exec(ctx, `UPDATE school_years SET is_current = false WHERE school_id = $1`, schoolID) if err != nil { return fmt.Errorf("failed to unset current school years: %w", err) } // Then set the specified school year as current _, err = s.db.Pool.Exec(ctx, `UPDATE school_years SET is_current = true WHERE id = $1 AND school_id = $2`, schoolYearID, schoolID) if err != nil { return fmt.Errorf("failed to set current school year: %w", err) } return nil } // GetCurrentSchoolYear gets the current school year for a school func (s *SchoolService) GetCurrentSchoolYear(ctx context.Context, schoolID uuid.UUID) (*models.SchoolYear, error) { query := ` SELECT id, school_id, name, start_date, end_date, is_current, created_at FROM school_years WHERE school_id = $1 AND is_current = true` schoolYear := &models.SchoolYear{} err := s.db.Pool.QueryRow(ctx, query, schoolID).Scan( &schoolYear.ID, &schoolYear.SchoolID, &schoolYear.Name, &schoolYear.StartDate, &schoolYear.EndDate, &schoolYear.IsCurrent, &schoolYear.CreatedAt, ) if err != nil { return nil, fmt.Errorf("failed to get current school year: %w", err) } return schoolYear, nil } // ======================================== // Class Management // ======================================== // CreateClass creates a new class func (s *SchoolService) CreateClass(ctx context.Context, schoolID uuid.UUID, req models.CreateClassRequest) (*models.Class, error) { schoolYearID, err := uuid.Parse(req.SchoolYearID) if err != nil { return nil, fmt.Errorf("invalid school year ID: %w", err) } class := &models.Class{ ID: uuid.New(), SchoolID: schoolID, SchoolYearID: schoolYearID, Name: req.Name, Grade: req.Grade, Section: req.Section, Room: req.Room, IsActive: true, CreatedAt: time.Now(), UpdatedAt: time.Now(), } query := ` INSERT INTO classes (id, school_id, school_year_id, name, grade, section, room, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id` err = s.db.Pool.QueryRow(ctx, query, class.ID, class.SchoolID, class.SchoolYearID, class.Name, class.Grade, class.Section, class.Room, class.IsActive, class.CreatedAt, class.UpdatedAt, ).Scan(&class.ID) if err != nil { return nil, fmt.Errorf("failed to create class: %w", err) } return class, nil } // GetClass retrieves a class by ID func (s *SchoolService) GetClass(ctx context.Context, classID uuid.UUID) (*models.Class, error) { query := ` SELECT id, school_id, school_year_id, name, grade, section, room, matrix_info_room, matrix_rep_room, is_active, created_at, updated_at FROM classes WHERE id = $1` class := &models.Class{} err := s.db.Pool.QueryRow(ctx, query, classID).Scan( &class.ID, &class.SchoolID, &class.SchoolYearID, &class.Name, &class.Grade, &class.Section, &class.Room, &class.MatrixInfoRoom, &class.MatrixRepRoom, &class.IsActive, &class.CreatedAt, &class.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("failed to get class: %w", err) } return class, nil } // ListClasses lists all classes for a school in a school year func (s *SchoolService) ListClasses(ctx context.Context, schoolID, schoolYearID uuid.UUID) ([]models.Class, error) { query := ` SELECT id, school_id, school_year_id, name, grade, section, room, matrix_info_room, matrix_rep_room, is_active, created_at, updated_at FROM classes WHERE school_id = $1 AND school_year_id = $2 AND is_active = true ORDER BY grade, name` rows, err := s.db.Pool.Query(ctx, query, schoolID, schoolYearID) if err != nil { return nil, fmt.Errorf("failed to list classes: %w", err) } defer rows.Close() var classes []models.Class for rows.Next() { var class models.Class err := rows.Scan( &class.ID, &class.SchoolID, &class.SchoolYearID, &class.Name, &class.Grade, &class.Section, &class.Room, &class.MatrixInfoRoom, &class.MatrixRepRoom, &class.IsActive, &class.CreatedAt, &class.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("failed to scan class: %w", err) } classes = append(classes, class) } return classes, nil } // ======================================== // Student Management // ======================================== // CreateStudent creates a new student func (s *SchoolService) CreateStudent(ctx context.Context, schoolID uuid.UUID, req models.CreateStudentRequest) (*models.Student, error) { classID, err := uuid.Parse(req.ClassID) if err != nil { return nil, fmt.Errorf("invalid class ID: %w", err) } student := &models.Student{ ID: uuid.New(), SchoolID: schoolID, ClassID: classID, StudentNumber: req.StudentNumber, FirstName: req.FirstName, LastName: req.LastName, Gender: req.Gender, IsActive: true, CreatedAt: time.Now(), UpdatedAt: time.Now(), } if req.DateOfBirth != nil { dob, err := time.Parse("2006-01-02", *req.DateOfBirth) if err == nil { student.DateOfBirth = &dob } } query := ` INSERT INTO students (id, school_id, class_id, student_number, first_name, last_name, date_of_birth, gender, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id` err = s.db.Pool.QueryRow(ctx, query, student.ID, student.SchoolID, student.ClassID, student.StudentNumber, student.FirstName, student.LastName, student.DateOfBirth, student.Gender, student.IsActive, student.CreatedAt, student.UpdatedAt, ).Scan(&student.ID) if err != nil { return nil, fmt.Errorf("failed to create student: %w", err) } return student, nil } // GetStudent retrieves a student by ID func (s *SchoolService) GetStudent(ctx context.Context, studentID uuid.UUID) (*models.Student, error) { query := ` SELECT id, school_id, class_id, user_id, student_number, first_name, last_name, date_of_birth, gender, matrix_user_id, matrix_dm_room, is_active, created_at, updated_at FROM students WHERE id = $1` student := &models.Student{} err := s.db.Pool.QueryRow(ctx, query, studentID).Scan( &student.ID, &student.SchoolID, &student.ClassID, &student.UserID, &student.StudentNumber, &student.FirstName, &student.LastName, &student.DateOfBirth, &student.Gender, &student.MatrixUserID, &student.MatrixDMRoom, &student.IsActive, &student.CreatedAt, &student.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("failed to get student: %w", err) } return student, nil } // ListStudentsByClass lists all students in a class func (s *SchoolService) ListStudentsByClass(ctx context.Context, classID uuid.UUID) ([]models.Student, error) { query := ` SELECT id, school_id, class_id, user_id, student_number, first_name, last_name, date_of_birth, gender, matrix_user_id, matrix_dm_room, is_active, created_at, updated_at FROM students WHERE class_id = $1 AND is_active = true ORDER BY last_name, first_name` rows, err := s.db.Pool.Query(ctx, query, classID) if err != nil { return nil, fmt.Errorf("failed to list students: %w", err) } defer rows.Close() var students []models.Student for rows.Next() { var student models.Student err := rows.Scan( &student.ID, &student.SchoolID, &student.ClassID, &student.UserID, &student.StudentNumber, &student.FirstName, &student.LastName, &student.DateOfBirth, &student.Gender, &student.MatrixUserID, &student.MatrixDMRoom, &student.IsActive, &student.CreatedAt, &student.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("failed to scan student: %w", err) } students = append(students, student) } return students, nil } // ======================================== // Teacher Management // ======================================== // CreateTeacher creates a new teacher linked to a user account func (s *SchoolService) CreateTeacher(ctx context.Context, schoolID, userID uuid.UUID, firstName, lastName string, teacherCode, title *string) (*models.Teacher, error) { teacher := &models.Teacher{ ID: uuid.New(), SchoolID: schoolID, UserID: userID, TeacherCode: teacherCode, Title: title, FirstName: firstName, LastName: lastName, IsActive: true, CreatedAt: time.Now(), UpdatedAt: time.Now(), } query := ` INSERT INTO teachers (id, school_id, user_id, teacher_code, title, first_name, last_name, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id` err := s.db.Pool.QueryRow(ctx, query, teacher.ID, teacher.SchoolID, teacher.UserID, teacher.TeacherCode, teacher.Title, teacher.FirstName, teacher.LastName, teacher.IsActive, teacher.CreatedAt, teacher.UpdatedAt, ).Scan(&teacher.ID) if err != nil { return nil, fmt.Errorf("failed to create teacher: %w", err) } return teacher, nil } // GetTeacher retrieves a teacher by ID func (s *SchoolService) GetTeacher(ctx context.Context, teacherID uuid.UUID) (*models.Teacher, error) { query := ` SELECT id, school_id, user_id, teacher_code, title, first_name, last_name, matrix_user_id, is_active, created_at, updated_at FROM teachers WHERE id = $1` teacher := &models.Teacher{} err := s.db.Pool.QueryRow(ctx, query, teacherID).Scan( &teacher.ID, &teacher.SchoolID, &teacher.UserID, &teacher.TeacherCode, &teacher.Title, &teacher.FirstName, &teacher.LastName, &teacher.MatrixUserID, &teacher.IsActive, &teacher.CreatedAt, &teacher.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("failed to get teacher: %w", err) } return teacher, nil } // GetTeacherByUserID retrieves a teacher by their user ID func (s *SchoolService) GetTeacherByUserID(ctx context.Context, userID uuid.UUID) (*models.Teacher, error) { query := ` SELECT id, school_id, user_id, teacher_code, title, first_name, last_name, matrix_user_id, is_active, created_at, updated_at FROM teachers WHERE user_id = $1 AND is_active = true` teacher := &models.Teacher{} err := s.db.Pool.QueryRow(ctx, query, userID).Scan( &teacher.ID, &teacher.SchoolID, &teacher.UserID, &teacher.TeacherCode, &teacher.Title, &teacher.FirstName, &teacher.LastName, &teacher.MatrixUserID, &teacher.IsActive, &teacher.CreatedAt, &teacher.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("failed to get teacher by user ID: %w", err) } return teacher, nil } // AssignClassTeacher assigns a teacher to a class func (s *SchoolService) AssignClassTeacher(ctx context.Context, classID, teacherID uuid.UUID, isPrimary bool) error { query := ` INSERT INTO class_teachers (id, class_id, teacher_id, is_primary, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (class_id, teacher_id) DO UPDATE SET is_primary = EXCLUDED.is_primary` _, err := s.db.Pool.Exec(ctx, query, uuid.New(), classID, teacherID, isPrimary, time.Now()) if err != nil { return fmt.Errorf("failed to assign class teacher: %w", err) } return nil } // ======================================== // Subject Management // ======================================== // CreateSubject creates a new subject func (s *SchoolService) CreateSubject(ctx context.Context, schoolID uuid.UUID, name, shortName string, color *string) (*models.Subject, error) { subject := &models.Subject{ ID: uuid.New(), SchoolID: schoolID, Name: name, ShortName: shortName, Color: color, IsActive: true, CreatedAt: time.Now(), } query := ` INSERT INTO subjects (id, school_id, name, short_name, color, is_active, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id` err := s.db.Pool.QueryRow(ctx, query, subject.ID, subject.SchoolID, subject.Name, subject.ShortName, subject.Color, subject.IsActive, subject.CreatedAt, ).Scan(&subject.ID) if err != nil { return nil, fmt.Errorf("failed to create subject: %w", err) } return subject, nil } // ListSubjects lists all subjects for a school func (s *SchoolService) ListSubjects(ctx context.Context, schoolID uuid.UUID) ([]models.Subject, error) { query := ` SELECT id, school_id, name, short_name, color, is_active, created_at FROM subjects WHERE school_id = $1 AND is_active = true ORDER BY name` rows, err := s.db.Pool.Query(ctx, query, schoolID) if err != nil { return nil, fmt.Errorf("failed to list subjects: %w", err) } defer rows.Close() var subjects []models.Subject for rows.Next() { var subject models.Subject err := rows.Scan( &subject.ID, &subject.SchoolID, &subject.Name, &subject.ShortName, &subject.Color, &subject.IsActive, &subject.CreatedAt, ) if err != nil { return nil, fmt.Errorf("failed to scan subject: %w", err) } subjects = append(subjects, subject) } return subjects, nil } // ======================================== // Parent Onboarding // ======================================== // GenerateParentOnboardingToken generates a QR code token for parent onboarding func (s *SchoolService) GenerateParentOnboardingToken(ctx context.Context, schoolID, classID, studentID, createdByUserID uuid.UUID, role string) (*models.ParentOnboardingToken, error) { // Generate secure random token tokenBytes := make([]byte, 32) if _, err := rand.Read(tokenBytes); err != nil { return nil, fmt.Errorf("failed to generate token: %w", err) } token := hex.EncodeToString(tokenBytes) onboardingToken := &models.ParentOnboardingToken{ ID: uuid.New(), SchoolID: schoolID, ClassID: classID, StudentID: studentID, Token: token, Role: role, ExpiresAt: time.Now().Add(72 * time.Hour), // Valid for 72 hours CreatedAt: time.Now(), CreatedBy: createdByUserID, } query := ` INSERT INTO parent_onboarding_tokens (id, school_id, class_id, student_id, token, role, expires_at, created_at, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id` err := s.db.Pool.QueryRow(ctx, query, onboardingToken.ID, onboardingToken.SchoolID, onboardingToken.ClassID, onboardingToken.StudentID, onboardingToken.Token, onboardingToken.Role, onboardingToken.ExpiresAt, onboardingToken.CreatedAt, onboardingToken.CreatedBy, ).Scan(&onboardingToken.ID) if err != nil { return nil, fmt.Errorf("failed to create onboarding token: %w", err) } return onboardingToken, nil } // ValidateOnboardingToken validates and retrieves info for an onboarding token func (s *SchoolService) ValidateOnboardingToken(ctx context.Context, token string) (*models.ParentOnboardingToken, error) { query := ` SELECT id, school_id, class_id, student_id, token, role, expires_at, used_at, used_by_user_id, created_at, created_by FROM parent_onboarding_tokens WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()` onboardingToken := &models.ParentOnboardingToken{} err := s.db.Pool.QueryRow(ctx, query, token).Scan( &onboardingToken.ID, &onboardingToken.SchoolID, &onboardingToken.ClassID, &onboardingToken.StudentID, &onboardingToken.Token, &onboardingToken.Role, &onboardingToken.ExpiresAt, &onboardingToken.UsedAt, &onboardingToken.UsedByUserID, &onboardingToken.CreatedAt, &onboardingToken.CreatedBy, ) if err != nil { return nil, fmt.Errorf("invalid or expired token: %w", err) } return onboardingToken, nil } // RedeemOnboardingToken marks a token as used and creates the parent account func (s *SchoolService) RedeemOnboardingToken(ctx context.Context, token string, userID uuid.UUID) error { query := ` UPDATE parent_onboarding_tokens SET used_at = NOW(), used_by_user_id = $1 WHERE token = $2 AND used_at IS NULL AND expires_at > NOW()` result, err := s.db.Pool.Exec(ctx, query, userID, token) if err != nil { return fmt.Errorf("failed to redeem token: %w", err) } if result.RowsAffected() == 0 { return fmt.Errorf("token not found or already used") } return nil } // ======================================== // Helper Functions // ======================================== func (s *SchoolService) createDefaultTimetableSlots(ctx context.Context, schoolID uuid.UUID) error { slots := []struct { Number int StartTime string EndTime string IsBreak bool Name string }{ {1, "08:00", "08:45", false, "1. Stunde"}, {2, "08:45", "09:30", false, "2. Stunde"}, {3, "09:30", "09:50", true, "Erste Pause"}, {4, "09:50", "10:35", false, "3. Stunde"}, {5, "10:35", "11:20", false, "4. Stunde"}, {6, "11:20", "11:40", true, "Zweite Pause"}, {7, "11:40", "12:25", false, "5. Stunde"}, {8, "12:25", "13:10", false, "6. Stunde"}, {9, "13:10", "14:00", true, "Mittagspause"}, {10, "14:00", "14:45", false, "7. Stunde"}, {11, "14:45", "15:30", false, "8. Stunde"}, } query := ` INSERT INTO timetable_slots (id, school_id, slot_number, start_time, end_time, is_break, name) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (school_id, slot_number) DO NOTHING` for _, slot := range slots { _, err := s.db.Pool.Exec(ctx, query, uuid.New(), schoolID, slot.Number, slot.StartTime, slot.EndTime, slot.IsBreak, slot.Name, ) if err != nil { return err } } return nil } func (s *SchoolService) createDefaultGradeScale(ctx context.Context, schoolID uuid.UUID) error { query := ` INSERT INTO grade_scales (id, school_id, name, min_value, max_value, passing_value, is_ascending, is_default, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT DO NOTHING` _, err := s.db.Pool.Exec(ctx, query, uuid.New(), schoolID, "1-6 (Noten)", 1.0, 6.0, 4.0, false, true, time.Now(), ) return err }