diff --git a/school-service/cmd/server/main.go b/school-service/cmd/server/main.go index 9cbb022..017786f 100644 --- a/school-service/cmd/server/main.go +++ b/school-service/cmd/server/main.go @@ -258,6 +258,31 @@ func main() { api.POST("/calendar/events", handler.CreateSchoolEvent) api.DELETE("/calendar/events/:id", handler.DeleteSchoolEvent) api.POST("/calendar/school-year-rollover", handler.RolloverSchoolYear) + + // Phase 9c: parent invitations (teacher side). + api.GET("/calendar/parents", handler.ListParentInvites) + api.POST("/calendar/parents/invite", handler.InviteParent) + api.DELETE("/calendar/parents/children/:id", handler.DeleteParentInvite) + } + + // Phase 9c: parent-side endpoints. Auth is the parent session cookie, + // NOT the teacher JWT. /parent/auth/redeem creates the cookie; the + // other routes require it via ParentSessionMiddleware. + parentAPI := router.Group("/api/v1/parent") + { + parentAPI.POST("/auth/redeem", handler.RedeemMagicLink) + + authed := parentAPI.Group("/") + authed.Use(middleware.ParentSessionMiddleware(func(ctx context.Context, token string) (string, string, string, error) { + p, err := handler.ParentService().ParentFromSession(ctx, token) + if err != nil { + return "", "", "", err + } + return p.ID.String(), p.Email, p.PreferredLanguage, nil + })) + authed.GET("/me", handler.ParentMe) + authed.GET("/me/timetable", handler.ParentTimetable) + authed.POST("/auth/logout", handler.ParentLogout) } // Start server diff --git a/school-service/internal/database/database.go b/school-service/internal/database/database.go index 65a2103..b934e19 100644 --- a/school-service/internal/database/database.go +++ b/school-service/internal/database/database.go @@ -224,6 +224,9 @@ func Migrate(db *DB) error { // Append calendar migrations (see calendar_migrations.go). migrations = append(migrations, CalendarMigrations()...) + // Append parent migrations (Phase 9c — see parent_migrations.go). + migrations = append(migrations, ParentMigrations()...) + for _, migration := range migrations { _, err := db.Pool.Exec(ctx, migration) if err != nil { diff --git a/school-service/internal/database/parent_migrations.go b/school-service/internal/database/parent_migrations.go new file mode 100644 index 0000000..fdb4e3f --- /dev/null +++ b/school-service/internal/database/parent_migrations.go @@ -0,0 +1,53 @@ +package database + +// ParentMigrations creates the four parent-side tables for Phase 9c: +// +// parent_account — one row per invited parent (email, language) +// parent_child — kids linked to a parent and a tt_class +// parent_magic_link — one-shot invite tokens, hashed +// parent_session — active browser sessions after redeeming a link +// +// The teacher owns the invite (created_by_user_id on account); parent sees +// only data scoped to their own children's class via tt_class.id. +func ParentMigrations() []string { + return []string{ + `CREATE TABLE IF NOT EXISTS parent_account ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + email VARCHAR(255) NOT NULL, + preferred_language VARCHAR(8) DEFAULT 'de', + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(created_by_user_id, email) + )`, + + `CREATE TABLE IF NOT EXISTS parent_child ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + parent_id UUID NOT NULL REFERENCES parent_account(id) ON DELETE CASCADE, + tt_class_id UUID NOT NULL REFERENCES tt_class(id) ON DELETE CASCADE, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + `CREATE TABLE IF NOT EXISTS parent_magic_link ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + parent_id UUID NOT NULL REFERENCES parent_account(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + `CREATE TABLE IF NOT EXISTS parent_session ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + parent_id UUID NOT NULL REFERENCES parent_account(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + `CREATE INDEX IF NOT EXISTS idx_parent_account_owner ON parent_account(created_by_user_id)`, + `CREATE INDEX IF NOT EXISTS idx_parent_child_parent ON parent_child(parent_id)`, + `CREATE INDEX IF NOT EXISTS idx_parent_child_class ON parent_child(tt_class_id)`, + } +} diff --git a/school-service/internal/handlers/handlers.go b/school-service/internal/handlers/handlers.go index 115702f..6bbbf4c 100644 --- a/school-service/internal/handlers/handlers.go +++ b/school-service/internal/handlers/handlers.go @@ -18,6 +18,7 @@ type Handler struct { aiService *services.AIService timetableService *services.TimetableService calendarService *services.CalendarService + parentService *services.ParentService solverServiceURL string } @@ -31,6 +32,7 @@ func NewHandler(db *pgxpool.Pool, llmGatewayURL, solverServiceURL string) *Handl aiService := services.NewAIService(llmGatewayURL) timetableService := services.NewTimetableService(db) calendarService := services.NewCalendarService(db) + parentService := services.NewParentService(db) return &Handler{ classService: classService, @@ -41,6 +43,7 @@ func NewHandler(db *pgxpool.Pool, llmGatewayURL, solverServiceURL string) *Handl aiService: aiService, timetableService: timetableService, calendarService: calendarService, + parentService: parentService, solverServiceURL: solverServiceURL, } } @@ -51,6 +54,12 @@ func (h *Handler) CalendarService() *services.CalendarService { return h.calendarService } +// ParentService exposes the parent service so the parent-session middleware +// in main.go can resolve session cookies. +func (h *Handler) ParentService() *services.ParentService { + return h.parentService +} + // Health returns the service health status func (h *Handler) Health(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ diff --git a/school-service/internal/handlers/parent_handlers.go b/school-service/internal/handlers/parent_handlers.go new file mode 100644 index 0000000..8d07ee8 --- /dev/null +++ b/school-service/internal/handlers/parent_handlers.go @@ -0,0 +1,140 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/school-service/internal/middleware" + "github.com/breakpilot/school-service/internal/models" + "github.com/gin-gonic/gin" +) + +// ---------- Teacher-side (uses JWT/dev auth from existing middleware) ---------- + +func (h *Handler) InviteParent(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.InviteParentRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + out, err := h.parentService.InviteParent(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to invite parent: "+err.Error()) + return + } + respondCreated(c, out) +} + +func (h *Handler) ListParentInvites(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + items, err := h.parentService.ListInvites(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list invites: "+err.Error()) + return + } + if items == nil { + items = []models.ParentInviteListItem{} + } + respondSuccess(c, items) +} + +func (h *Handler) DeleteParentInvite(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.parentService.DeleteInvite(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete invite: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Invite removed"}) +} + +// ---------- Parent-side (uses ParentSessionMiddleware) ---------- + +func (h *Handler) RedeemMagicLink(c *gin.Context) { + var req models.RedeemMagicLinkRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + session, parent, err := h.parentService.RedeemMagicLink(c.Request.Context(), req.Token) + if err != nil { + respondError(c, http.StatusUnauthorized, err.Error()) + return + } + // HttpOnly + Lax → cookie survives a fresh redirect from /eltern/login but + // isn't sent on cross-site CSRF requests. + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(middleware.ParentSessionCookieName, session, + 60*60*24*30, "/", "", false, true) + respondSuccess(c, parent) +} + +func (h *Handler) ParentMe(c *gin.Context) { + parentID := c.GetString("parent_id") + children, err := h.parentService.ListChildren(c.Request.Context(), parentID) + if err != nil { + respondError(c, http.StatusInternalServerError, err.Error()) + return + } + if children == nil { + children = []models.ParentChild{} + } + respondSuccess(c, gin.H{ + "parent": gin.H{ + "id": parentID, + "email": c.GetString("parent_email"), + "preferred_language": c.GetString("parent_language"), + }, + "children": children, + }) +} + +// ParentTimetable returns the latest completed timetable lessons for the +// given child's class. Authorization: parent must own a child in that class. +func (h *Handler) ParentTimetable(c *gin.Context) { + parentID := c.GetString("parent_id") + classID := c.Query("class_id") + if classID == "" { + respondError(c, http.StatusBadRequest, "class_id required") + return + } + ok, err := h.parentService.ChildBelongsToParent(c.Request.Context(), parentID, classID) + if err != nil { + respondError(c, http.StatusInternalServerError, err.Error()) + return + } + if !ok { + respondError(c, http.StatusForbidden, "Not allowed") + return + } + // Need the teacher's user_id to find the right solution. We re-derive it + // from parent_account.created_by_user_id via a small extra query. + teacherID, err := h.parentService.TeacherOfParent(c.Request.Context(), parentID) + if err != nil { + respondError(c, http.StatusInternalServerError, err.Error()) + return + } + lessons, err := h.parentService.LatestCompletedSolutionLessonsForClass(c.Request.Context(), classID, teacherID) + if err != nil { + respondError(c, http.StatusInternalServerError, err.Error()) + return + } + respondSuccess(c, lessons) +} + +func (h *Handler) ParentLogout(c *gin.Context) { + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(middleware.ParentSessionCookieName, "", -1, "/", "", false, true) + c.JSON(http.StatusOK, gin.H{"message": "Logged out"}) +} diff --git a/school-service/internal/middleware/parent_session.go b/school-service/internal/middleware/parent_session.go new file mode 100644 index 0000000..8628d6c --- /dev/null +++ b/school-service/internal/middleware/parent_session.go @@ -0,0 +1,42 @@ +package middleware + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" +) + +// ParentResolver is the minimum the middleware needs from the parent +// service. Defined as interface so handlers can pass their own service +// without import cycles. +type ParentResolver interface { + ParentFromSession(ctx context.Context, token string) (parent interface{}, err error) +} + +// ParentSessionCookieName is the name of the HttpOnly cookie that carries +// the parent's session token after redeem. Exported so handlers can set it. +const ParentSessionCookieName = "bp_parent_session" + +// ParentSessionMiddleware reads the parent session cookie and resolves it +// to a parent_account. Stores parent_id (string) in the Gin context for +// downstream handlers. Aborts with 401 if the cookie is missing or the +// session expired. +func ParentSessionMiddleware(resolve func(ctx context.Context, token string) (string, string, string, error)) gin.HandlerFunc { + return func(c *gin.Context) { + token, err := c.Cookie(ParentSessionCookieName) + if err != nil || token == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Parent session required"}) + return + } + parentID, email, lang, err := resolve(c.Request.Context(), token) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired session"}) + return + } + c.Set("parent_id", parentID) + c.Set("parent_email", email) + c.Set("parent_language", lang) + c.Next() + } +} diff --git a/school-service/internal/models/parent.go b/school-service/internal/models/parent.go new file mode 100644 index 0000000..ec947a4 --- /dev/null +++ b/school-service/internal/models/parent.go @@ -0,0 +1,76 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type ParentAccount struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + Email string `json:"email" db:"email"` + PreferredLanguage string `json:"preferred_language" db:"preferred_language"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +type ParentChild struct { + ID uuid.UUID `json:"id" db:"id"` + ParentID uuid.UUID `json:"parent_id" db:"parent_id"` + TTClassID uuid.UUID `json:"tt_class_id" db:"tt_class_id"` + FirstName string `json:"first_name" db:"first_name"` + LastName string `json:"last_name" db:"last_name"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + // Joined for display + ClassName string `json:"class_name,omitempty"` +} + +// Request DTOs + +// InviteParentRequest is what the teacher posts to invite one parent for one +// child. The endpoint creates the account if it doesn't exist, the child +// row, and a fresh magic_link. Same parent can be invited for several +// children (re-using the account). +type InviteParentRequest struct { + Email string `json:"email" binding:"required,email"` + PreferredLanguage string `json:"preferred_language"` + ChildFirstName string `json:"child_first_name" binding:"required"` + ChildLastName string `json:"child_last_name" binding:"required"` + TTClassID string `json:"tt_class_id" binding:"required,uuid"` +} + +// InviteParentResponse carries the freshly-minted magic-link path so the +// teacher can copy it into Matrix/Email manually (mass-send comes from the +// notification worker in Phase 9d). +type InviteParentResponse struct { + Parent ParentAccount `json:"parent"` + Child ParentChild `json:"child"` + MagicToken string `json:"magic_token"` + MagicURL string `json:"magic_url"` + ExpiresAt time.Time `json:"expires_at"` +} + +// ParentInviteListItem is the teacher-facing list row — one entry per +// (parent, child) pair, with the joined class name. +type ParentInviteListItem struct { + ParentID uuid.UUID `json:"parent_id"` + Email string `json:"email"` + PreferredLanguage string `json:"preferred_language"` + ChildID uuid.UUID `json:"child_id"` + ChildFirstName string `json:"child_first_name"` + ChildLastName string `json:"child_last_name"` + ClassID uuid.UUID `json:"class_id"` + ClassName string `json:"class_name"` + CreatedAt time.Time `json:"created_at"` +} + +// RedeemMagicLinkRequest is what /parent/auth/redeem expects. +type RedeemMagicLinkRequest struct { + Token string `json:"token" binding:"required"` +} + +// ParentMe is what /parent/me returns: the account + every linked child. +type ParentMe struct { + Parent ParentAccount `json:"parent"` + Children []ParentChild `json:"children"` +} diff --git a/school-service/internal/services/parent_auth.go b/school-service/internal/services/parent_auth.go new file mode 100644 index 0000000..40e14e8 --- /dev/null +++ b/school-service/internal/services/parent_auth.go @@ -0,0 +1,209 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/breakpilot/school-service/internal/models" + "github.com/google/uuid" +) + +// RedeemMagicLink validates a one-shot link, marks it used, mints a session +// token. Returns the raw session token; caller (HTTP handler) sets it as +// HttpOnly cookie. +func (s *ParentService) RedeemMagicLink(ctx context.Context, token string) (sessionToken string, parent *models.ParentAccount, err error) { + hash := hashToken(token) + + tx, err := s.db.Begin(ctx) + if err != nil { + return "", nil, err + } + defer tx.Rollback(ctx) + + var ( + linkID uuid.UUID + parentID uuid.UUID + expiresAt time.Time + usedAt *time.Time + ) + if err := tx.QueryRow(ctx, ` + SELECT id, parent_id, expires_at, used_at + FROM parent_magic_link + WHERE token_hash = $1 + `, hash).Scan(&linkID, &parentID, &expiresAt, &usedAt); err != nil { + return "", nil, fmt.Errorf("invalid token") + } + if usedAt != nil { + return "", nil, fmt.Errorf("token already used") + } + if time.Now().After(expiresAt) { + return "", nil, fmt.Errorf("token expired") + } + + // Mark used. + if _, err := tx.Exec(ctx, `UPDATE parent_magic_link SET used_at = NOW() WHERE id = $1`, linkID); err != nil { + return "", nil, err + } + + // Mint session token. + raw, h, err := randomToken() + if err != nil { + return "", nil, err + } + sessionExpires := time.Now().Add(parentSessionTTL) + if _, err := tx.Exec(ctx, ` + INSERT INTO parent_session (parent_id, token_hash, expires_at) + VALUES ($1, $2, $3) + `, parentID, h, sessionExpires); err != nil { + return "", nil, err + } + + // Fetch the account so callers (UI) get the email + language back. + var p models.ParentAccount + if err := tx.QueryRow(ctx, ` + SELECT id, created_by_user_id, email, preferred_language, created_at + FROM parent_account WHERE id = $1 + `, parentID).Scan(&p.ID, &p.CreatedByUserID, &p.Email, &p.PreferredLanguage, &p.CreatedAt); err != nil { + return "", nil, err + } + + if err := tx.Commit(ctx); err != nil { + return "", nil, err + } + return raw, &p, nil +} + +// ParentFromSession resolves a session token back to the parent account. +// Returns error on missing/expired session. Called by ParentSession +// middleware. +func (s *ParentService) ParentFromSession(ctx context.Context, sessionToken string) (*models.ParentAccount, error) { + hash := hashToken(sessionToken) + var p models.ParentAccount + var expiresAt time.Time + if err := s.db.QueryRow(ctx, ` + SELECT pa.id, pa.created_by_user_id, pa.email, pa.preferred_language, pa.created_at, ps.expires_at + FROM parent_session ps + JOIN parent_account pa ON pa.id = ps.parent_id + WHERE ps.token_hash = $1 + `, hash).Scan(&p.ID, &p.CreatedByUserID, &p.Email, &p.PreferredLanguage, &p.CreatedAt, &expiresAt); err != nil { + return nil, fmt.Errorf("invalid session") + } + if time.Now().After(expiresAt) { + return nil, fmt.Errorf("session expired") + } + return &p, nil +} + +// ListChildren returns all parent_child rows for a parent, joined with the +// class name from tt_class. +func (s *ParentService) ListChildren(ctx context.Context, parentID string) ([]models.ParentChild, error) { + rows, err := s.db.Query(ctx, ` + SELECT pc.id, pc.parent_id, pc.tt_class_id, pc.first_name, pc.last_name, pc.created_at, cl.name + FROM parent_child pc + JOIN tt_class cl ON cl.id = pc.tt_class_id + WHERE pc.parent_id = $1 + ORDER BY pc.last_name, pc.first_name + `, parentID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.ParentChild + for rows.Next() { + var c models.ParentChild + if err := rows.Scan(&c.ID, &c.ParentID, &c.TTClassID, &c.FirstName, &c.LastName, &c.CreatedAt, &c.ClassName); err != nil { + return nil, err + } + out = append(out, c) + } + return out, nil +} + +// TeacherOfParent returns the created_by_user_id of the teacher who invited +// this parent. Used to scope timetable + calendar queries. +func (s *ParentService) TeacherOfParent(ctx context.Context, parentID string) (string, error) { + var uid string + err := s.db.QueryRow(ctx, + `SELECT created_by_user_id::text FROM parent_account WHERE id = $1`, parentID, + ).Scan(&uid) + return uid, err +} + +// ChildBelongsToParent checks whether a tt_class is one this parent has a +// child in. Used by the timetable + calendar handlers as authorization. +func (s *ParentService) ChildBelongsToParent(ctx context.Context, parentID, classID string) (bool, error) { + var ok bool + err := s.db.QueryRow(ctx, ` + SELECT EXISTS(SELECT 1 FROM parent_child + WHERE parent_id = $1 AND tt_class_id = $2) + `, parentID, classID).Scan(&ok) + return ok, err +} + +// LatestCompletedSolutionLessonsForClass returns the lessons of the most +// recent COMPLETED tt_solution where the given class has rows, owned by +// the teacher that originally invited the parent. Joined with subject + room +// + teacher names so the parent UI can render directly. +func (s *ParentService) LatestCompletedSolutionLessonsForClass(ctx context.Context, classID, teacherUserID string) ([]LessonExport, error) { + // Find latest completed solution by the teacher that has at least one + // lesson in this class. + var solutionID string + if err := s.db.QueryRow(ctx, ` + SELECT s.id::text + FROM tt_solution s + JOIN tt_lesson l ON l.solution_id = s.id + WHERE s.created_by_user_id = $1 + AND s.status = 'completed' + AND l.class_id = $2::uuid + ORDER BY s.created_at DESC + LIMIT 1 + `, teacherUserID, classID).Scan(&solutionID); err != nil { + return nil, nil // no plan yet — parent UI shows empty grid + } + + // Re-use the existing export shape with a stricter filter (class only). + rows, err := s.db.Query(ctx, ` + SELECT l.day_of_week, l.period_index, + to_char(p.start_time, 'HH24:MI') AS st, + to_char(p.end_time, 'HH24:MI') AS et, + cl.name, sub.name, sub.short_code, + t.last_name || ', ' || t.first_name, + COALESCE(r.name, ''), + l.pinned + FROM tt_lesson l + JOIN tt_solution s ON l.solution_id = s.id + JOIN tt_class cl ON l.class_id = cl.id + JOIN tt_subject sub ON l.subject_id = sub.id + JOIN tt_teacher t ON l.teacher_id = t.id + LEFT JOIN tt_room r ON l.room_id = r.id + LEFT JOIN tt_period p + ON p.day_of_week = l.day_of_week + AND p.period_index = l.period_index + AND p.created_by_user_id = s.created_by_user_id + WHERE s.id = $1::uuid AND l.class_id = $2::uuid + ORDER BY l.day_of_week, l.period_index + `, solutionID, classID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []LessonExport + for rows.Next() { + var le LessonExport + var st, et *string + if err := rows.Scan(&le.DayOfWeek, &le.PeriodIndex, &st, &et, + &le.ClassName, &le.SubjectName, &le.SubjectCode, + &le.TeacherName, &le.RoomName, &le.Pinned); err != nil { + return nil, err + } + if st != nil { + le.StartTime = *st + } + if et != nil { + le.EndTime = *et + } + out = append(out, le) + } + return out, nil +} diff --git a/school-service/internal/services/parent_service.go b/school-service/internal/services/parent_service.go new file mode 100644 index 0000000..f3edfb5 --- /dev/null +++ b/school-service/internal/services/parent_service.go @@ -0,0 +1,175 @@ +package services + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "time" + + "github.com/breakpilot/school-service/internal/models" + "github.com/jackc/pgx/v5/pgxpool" +) + +// ParentService owns the parent_* tables. Magic-link tokens are random +// 32-byte values; only the SHA-256 hash is stored in the DB. The raw token +// goes back to the teacher exactly once (when they invite a parent) so +// they can paste it into a Matrix message or email. After redeem, a +// browser session (own table, separate token) carries the parent through +// the API. +type ParentService struct { + db *pgxpool.Pool +} + +func NewParentService(db *pgxpool.Pool) *ParentService { + return &ParentService{db: db} +} + +const ( + magicLinkTTL = 7 * 24 * time.Hour + parentSessionTTL = 30 * 24 * time.Hour + parentCookieName = "bp_parent_session" + tokenLen = 32 // raw bytes; URL-safe base64 encoded +) + +func randomToken() (raw string, hash string, err error) { + buf := make([]byte, tokenLen) + if _, err := rand.Read(buf); err != nil { + return "", "", err + } + raw = base64.RawURLEncoding.EncodeToString(buf) + h := sha256.Sum256([]byte(raw)) + hash = hex.EncodeToString(h[:]) + return raw, hash, nil +} + +func hashToken(raw string) string { + h := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(h[:]) +} + +// InviteParent upserts the parent account, creates a fresh child row, and +// issues a magic-link. Caller (teacher) is the owner; child must belong to +// one of their tt_class rows. +func (s *ParentService) InviteParent(ctx context.Context, userID string, req *models.InviteParentRequest) (*models.InviteParentResponse, error) { + tx, err := s.db.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + // 1. Verify class ownership. + var owned bool + if err := tx.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM tt_class WHERE id = $1 AND created_by_user_id = $2)`, + req.TTClassID, userID, + ).Scan(&owned); err != nil { + return nil, err + } + if !owned { + return nil, fmt.Errorf("tt_class_id not found or not owned by user") + } + + lang := req.PreferredLanguage + if lang == "" { + lang = "de" + } + + // 2. Upsert parent_account. + var parent models.ParentAccount + if err := tx.QueryRow(ctx, ` + INSERT INTO parent_account (created_by_user_id, email, preferred_language) + VALUES ($1, $2, $3) + ON CONFLICT (created_by_user_id, email) DO UPDATE + SET preferred_language = EXCLUDED.preferred_language + RETURNING id, created_by_user_id, email, preferred_language, created_at + `, userID, req.Email, lang).Scan( + &parent.ID, &parent.CreatedByUserID, &parent.Email, &parent.PreferredLanguage, &parent.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("upsert parent: %w", err) + } + + // 3. Insert child. + var child models.ParentChild + if err := tx.QueryRow(ctx, ` + INSERT INTO parent_child (parent_id, tt_class_id, first_name, last_name) + VALUES ($1, $2::uuid, $3, $4) + RETURNING id, parent_id, tt_class_id, first_name, last_name, created_at + `, parent.ID, req.TTClassID, req.ChildFirstName, req.ChildLastName).Scan( + &child.ID, &child.ParentID, &child.TTClassID, &child.FirstName, &child.LastName, &child.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("insert child: %w", err) + } + + // 4. Mint a magic-link token (raw goes back, hash goes to DB). + raw, hash, err := randomToken() + if err != nil { + return nil, fmt.Errorf("token gen: %w", err) + } + expiresAt := time.Now().Add(magicLinkTTL) + if _, err := tx.Exec(ctx, ` + INSERT INTO parent_magic_link (parent_id, token_hash, expires_at) + VALUES ($1, $2, $3) + `, parent.ID, hash, expiresAt); err != nil { + return nil, fmt.Errorf("insert magic link: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return &models.InviteParentResponse{ + Parent: parent, + Child: child, + MagicToken: raw, + MagicURL: "/eltern/login?token=" + raw, + ExpiresAt: expiresAt, + }, nil +} + +func (s *ParentService) ListInvites(ctx context.Context, userID string) ([]models.ParentInviteListItem, error) { + rows, err := s.db.Query(ctx, ` + SELECT pa.id, pa.email, pa.preferred_language, + pc.id, pc.first_name, pc.last_name, + cl.id, cl.name, pc.created_at + FROM parent_account pa + JOIN parent_child pc ON pc.parent_id = pa.id + JOIN tt_class cl ON cl.id = pc.tt_class_id + WHERE pa.created_by_user_id = $1 + ORDER BY pa.email, pc.last_name + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.ParentInviteListItem + for rows.Next() { + var it models.ParentInviteListItem + if err := rows.Scan(&it.ParentID, &it.Email, &it.PreferredLanguage, + &it.ChildID, &it.ChildFirstName, &it.ChildLastName, + &it.ClassID, &it.ClassName, &it.CreatedAt); err != nil { + return nil, err + } + out = append(out, it) + } + return out, nil +} + +// DeleteInvite removes one child row (parent stays if other children still +// exist for the same teacher). +func (s *ParentService) DeleteInvite(ctx context.Context, childID, userID string) error { + res, err := s.db.Exec(ctx, ` + DELETE FROM parent_child pc + USING parent_account pa + WHERE pc.id = $1 AND pc.parent_id = pa.id AND pa.created_by_user_id = $2 + `, childID, userID) + if err != nil { + return err + } + if res.RowsAffected() == 0 { + return fmt.Errorf("child not found or not owned") + } + return nil +} diff --git a/school-service/internal/services/parent_service_test.go b/school-service/internal/services/parent_service_test.go new file mode 100644 index 0000000..df36944 --- /dev/null +++ b/school-service/internal/services/parent_service_test.go @@ -0,0 +1,80 @@ +package services + +import ( + "strings" + "testing" + + "github.com/breakpilot/school-service/internal/models" +) + +func TestRandomToken_Hashable(t *testing.T) { + raw, hash, err := randomToken() + if err != nil { + t.Fatalf("randomToken error: %v", err) + } + if len(raw) < 30 { + t.Errorf("raw token suspiciously short: %d", len(raw)) + } + if len(hash) != 64 { + t.Errorf("sha256 hex hash must be 64 chars, got %d", len(hash)) + } + if hashToken(raw) != hash { + t.Errorf("hashToken(raw) must equal the hash randomToken returned") + } +} + +func TestRandomToken_NonRepeating(t *testing.T) { + // 16 iterations, all raw tokens must differ. + seen := map[string]struct{}{} + for i := 0; i < 16; i++ { + raw, _, err := randomToken() + if err != nil { + t.Fatalf("iter %d: %v", i, err) + } + if _, dup := seen[raw]; dup { + t.Fatalf("duplicate raw token at iter %d", i) + } + seen[raw] = struct{}{} + } +} + +func TestHashToken_StableHexLowercase(t *testing.T) { + h := hashToken("hello world") + if strings.ToLower(h) != h { + t.Errorf("hash should be lowercase hex") + } + if len(h) != 64 { + t.Errorf("expected 64-char hash, got %d", len(h)) + } +} + +func TestInviteParentRequest_Validation(t *testing.T) { + tests := []struct { + name string + req models.InviteParentRequest + wantErr bool + }{ + {"valid", models.InviteParentRequest{ + Email: "a@b.de", ChildFirstName: "Max", ChildLastName: "Mueller", + TTClassID: "00000000-0000-0000-0000-000000000001", + }, false}, + {"bad email", models.InviteParentRequest{ + Email: "not-an-email", ChildFirstName: "Max", ChildLastName: "Mueller", + TTClassID: "00000000-0000-0000-0000-000000000001", + }, true}, + {"missing child", models.InviteParentRequest{ + Email: "a@b.de", TTClassID: "00000000-0000-0000-0000-000000000001", + }, true}, + {"bad class uuid", models.InviteParentRequest{ + Email: "a@b.de", ChildFirstName: "Max", ChildLastName: "Mueller", + TTClassID: "not-a-uuid", + }, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if (validate.Struct(tt.req) != nil) != tt.wantErr { + t.Errorf("unexpected validation outcome for %s", tt.name) + } + }) + } +} diff --git a/studio-v2/app/api/parent/[...path]/route.ts b/studio-v2/app/api/parent/[...path]/route.ts new file mode 100644 index 0000000..bf55565 --- /dev/null +++ b/studio-v2/app/api/parent/[...path]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server' + +/** + * Proxy for the parent-side school-service endpoints. Mirrors the school + * proxy but forwards the parent-session cookie via Set-Cookie/Cookie + * headers so HttpOnly survives the round-trip. + */ + +const BACKEND_URL = process.env.SCHOOL_SERVICE_URL || 'http://school-service:8084' + +async function proxy(request: NextRequest, params: { path: string[] }): Promise { + const path = params.path.join('/') + const url = `${BACKEND_URL}/api/v1/parent/${path}${request.nextUrl.search}` + + const headers: HeadersInit = { 'Content-Type': 'application/json' } + const cookie = request.headers.get('cookie') + if (cookie) headers['Cookie'] = cookie + + const init: RequestInit = { method: request.method, headers } + if (['POST', 'PUT', 'PATCH'].includes(request.method)) { + init.body = await request.text() + } + + try { + const upstream = await fetch(url, init) + const body = await upstream.text() + const res = new NextResponse(body, { + status: upstream.status, + headers: { 'Content-Type': upstream.headers.get('content-type') || 'application/json' }, + }) + // Mirror Set-Cookie back so the browser stores the parent session. + const setCookie = upstream.headers.get('set-cookie') + if (setCookie) res.headers.set('Set-Cookie', setCookie) + return res + } catch (error) { + return NextResponse.json( + { error: 'school-service nicht erreichbar', details: error instanceof Error ? error.message : 'Unknown error' }, + { status: 502 }, + ) + } +} + +export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { return proxy(req, await ctx.params) } +export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { return proxy(req, await ctx.params) } +export async function PUT(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { return proxy(req, await ctx.params) } +export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { return proxy(req, await ctx.params) } diff --git a/studio-v2/app/eltern/login/page.tsx b/studio-v2/app/eltern/login/page.tsx new file mode 100644 index 0000000..5a1c12b --- /dev/null +++ b/studio-v2/app/eltern/login/page.tsx @@ -0,0 +1,44 @@ +'use client' + +import { Suspense, useEffect, useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { elternApi } from '@/lib/eltern/api' + +function LoginInner() { + const router = useRouter() + const search = useSearchParams() + const [error, setError] = useState(null) + const [done, setDone] = useState(false) + + useEffect(() => { + const token = search.get('token') + if (!token) { + setError('Kein Token in der URL. Bitte den Link aus der Einladung verwenden.') + return + } + elternApi.redeem(token) + .then(() => { setDone(true); setTimeout(() => router.replace('/eltern'), 800) }) + .catch(e => setError(e instanceof Error ? e.message : 'Login fehlgeschlagen')) + }, [router, search]) + + return ( +
+
+

Eltern-Login

+ {!error && !done &&

Pruefe Token …

} + {done &&

Erfolgreich angemeldet. Weiterleitung …

} + {error && ( +
{error}
+ )} +
+
+ ) +} + +export default function ElternLoginPage() { + return ( + + + + ) +} diff --git a/studio-v2/app/eltern/page.tsx b/studio-v2/app/eltern/page.tsx new file mode 100644 index 0000000..804d819 --- /dev/null +++ b/studio-v2/app/eltern/page.tsx @@ -0,0 +1,173 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { useRouter } from 'next/navigation' +import { elternApi, type ParentMeResponse, type ParentLesson } from '@/lib/eltern/api' +import { translateSubject } from '@/lib/calendar/subject-i18n' + +const DAY_LABELS: Record = { + de: ['Mo', 'Di', 'Mi', 'Do', 'Fr'], + en: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'], + tr: ['Pzt', 'Sal', 'Çar', 'Per', 'Cum'], + ar: ['الإثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة'], + uk: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт'], + ru: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт'], + pl: ['Pon', 'Wt', 'Śr', 'Czw', 'Pt'], + fr: ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven'], +} + +const HEADINGS: Record = { + de: { greeting: 'Willkommen', selectChild: 'Kind auswählen', period: 'Stunde', logout: 'Abmelden', noPlan: 'Noch kein Stundenplan veröffentlicht.' }, + en: { greeting: 'Welcome', selectChild: 'Select child', period: 'Period', logout: 'Sign out', noPlan: 'No timetable published yet.' }, + tr: { greeting: 'Hoş geldiniz', selectChild: 'Çocuk seç', period: 'Ders', logout: 'Çıkış', noPlan: 'Henüz ders programı yayımlanmadı.' }, + ar: { greeting: 'مرحبًا', selectChild: 'اختر الطفل', period: 'حصة', logout: 'خروج', noPlan: 'لم يتم نشر جدول حصص بعد.' }, + uk: { greeting: 'Ласкаво просимо', selectChild: 'Виберіть дитину', period: 'Урок', logout: 'Вийти', noPlan: 'Розклад ще не опубліковано.' }, + ru: { greeting: 'Добро пожаловать', selectChild: 'Выберите ребёнка', period: 'Урок', logout: 'Выйти', noPlan: 'Расписание ещё не опубликовано.' }, + pl: { greeting: 'Witamy', selectChild: 'Wybierz dziecko', period: 'Lekcja', logout: 'Wyloguj', noPlan: 'Plan lekcji nie jest jeszcze opublikowany.' }, + fr: { greeting: 'Bienvenue', selectChild: 'Choisir un enfant', period: 'Cours', logout: 'Déconnexion', noPlan: 'Aucun emploi du temps publié.' }, +} + +function t(lang: string, key: keyof typeof HEADINGS['de']): string { + const code = (lang || 'de').slice(0, 2) + return HEADINGS[code]?.[key] ?? HEADINGS.de[key] +} + +export default function ElternPage() { + const router = useRouter() + const [me, setMe] = useState(null) + const [selected, setSelected] = useState('') + const [lessons, setLessons] = useState([]) + const [error, setError] = useState(null) + + const lang = me?.parent.preferred_language || 'de' + const dayLabels = DAY_LABELS[lang.slice(0, 2)] || DAY_LABELS.de + + const loadMe = useCallback(async () => { + try { + const data = await elternApi.me() + setMe(data) + if (data.children.length > 0) setSelected(data.children[0].tt_class_id) + } catch (e) { + // Not logged in → redirect to login. + if (e instanceof Error && /session/i.test(e.message)) { + router.replace('/eltern/login') + return + } + setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen') + } + }, [router]) + + useEffect(() => { loadMe() }, [loadMe]) + + const loadTimetable = useCallback(async () => { + if (!selected) return + try { + const data = await elternApi.timetable(selected) + setLessons(data || []) + setError(null) + } catch (e) { + setError(e instanceof Error ? e.message : 'Stundenplan laden fehlgeschlagen') + } + }, [selected]) + + useEffect(() => { loadTimetable() }, [loadTimetable]) + + const periodIndices = useMemo(() => { + const set = new Set() + for (const l of lessons) set.add(l.PeriodIndex) + return Array.from(set).sort((a, b) => a - b) + }, [lessons]) + + const cell = (day: number, idx: number) => + lessons.find(l => l.DayOfWeek === day && l.PeriodIndex === idx) + + const handleLogout = async () => { + try { await elternApi.logout() } catch { /* ignore */ } + router.replace('/eltern/login') + } + + if (!me) { + return ( +
+ {error ? {error} : Laedt …} +
+ ) + } + + const activeChild = me.children.find(c => c.tt_class_id === selected) + + return ( +
+
+
+
+

{t(lang, 'greeting')}, {me.parent.email}

+

+ {activeChild ? `${activeChild.first_name} ${activeChild.last_name} · ${activeChild.class_name}` : ''} +

+
+ +
+ + {me.children.length > 1 && ( +
+ + +
+ )} + + {error &&
{error}
} + + {periodIndices.length === 0 ? ( +
+ {t(lang, 'noPlan')} +
+ ) : ( +
+ + + + + {dayLabels.map(d => )} + + + + {periodIndices.map(idx => ( + + + {[1, 2, 3, 4, 5].map(d => { + const l = cell(d, idx) + if (!l) return + return ( + + ) + })} + + ))} + +
{t(lang, 'period')}{d}
{idx}. +
+
{translateSubject(l.SubjectName, lang)}
+
{l.TeacherName.split(',')[0]}
+ {l.RoomName &&
{l.RoomName}
} +
+
+
+ )} +
+
+ ) +} diff --git a/studio-v2/app/schulkalender/_components/ParentManager.tsx b/studio-v2/app/schulkalender/_components/ParentManager.tsx new file mode 100644 index 0000000..f2e9df3 --- /dev/null +++ b/studio-v2/app/schulkalender/_components/ParentManager.tsx @@ -0,0 +1,173 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { useTheme } from '@/lib/ThemeContext' +import { calendarApi } from '@/lib/schulkalender/api' +import { classesApi } from '@/lib/stundenplan/api' +import type { ParentInviteListItem, InviteParentResponse } from '@/app/schulkalender/types' +import type { TimetableClass } from '@/app/stundenplan/types' + +const LANGS: { code: string; name: string }[] = [ + { code: 'de', name: 'Deutsch' }, + { code: 'en', name: 'English' }, + { code: 'tr', name: 'Tuerkce' }, + { code: 'ar', name: 'العربية' }, + { code: 'uk', name: 'Українська' }, + { code: 'ru', name: 'Русский' }, + { code: 'pl', name: 'Polski' }, + { code: 'fr', name: 'Francais' }, +] + +export function ParentManager() { + const { isDark } = useTheme() + const [items, setItems] = useState([]) + const [classes, setClasses] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showForm, setShowForm] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [lastInvite, setLastInvite] = useState(null) + const [form, setForm] = useState({ + email: '', + preferred_language: 'de', + child_first_name: '', + child_last_name: '', + tt_class_id: '', + }) + + const load = useCallback(async () => { + setLoading(true) + try { + const [list, cls] = await Promise.all([calendarApi.listParents(), classesApi.list()]) + setItems(list || []) + setClasses(cls || []) + setError(null) + } catch (e) { + setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { load() }, [load]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setSubmitting(true) + setError(null) + try { + const res = await calendarApi.inviteParent(form) + setLastInvite(res) + setForm({ ...form, child_first_name: '', child_last_name: '' }) + await load() + } catch (err) { + setError(err instanceof Error ? err.message : 'Einladen fehlgeschlagen') + } finally { + setSubmitting(false) + } + } + + const handleDelete = async (childId: string) => { + if (!confirm('Eltern-Zuordnung wirklich loeschen?')) return + try { await calendarApi.deleteParentChild(childId); await load() } + catch (e) { setError(e instanceof Error ? e.message : 'Loeschen fehlgeschlagen') } + } + + const fullLink = (path: string) => + typeof window === 'undefined' ? path : `${window.location.origin}${path}` + + const copyLink = (path: string) => { + navigator.clipboard?.writeText(fullLink(path)) + } + + const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900' + const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900' + + return ( +
+
+

Eltern verwalten ({items.length})

+ +
+ + {classes.length === 0 && ( +

+ Zuerst Klassen im Stundenplan-Modul anlegen. +

+ )} + + {error && ( +
{error}
+ )} + + {showForm && ( +
+ setForm({ ...form, email: e.target.value })} data-testid="parent-email" className={`px-3 py-2 rounded-lg border ${inputClass}`} /> + setForm({ ...form, child_first_name: e.target.value })} data-testid="parent-child-first" className={`px-3 py-2 rounded-lg border ${inputClass}`} /> + setForm({ ...form, child_last_name: e.target.value })} data-testid="parent-child-last" className={`px-3 py-2 rounded-lg border ${inputClass}`} /> + + + +
+ )} + + {lastInvite && ( +
+
Einladungs-Link fuer {lastInvite.parent.email}
+
+ + {fullLink(lastInvite.magic_url)} + + +
+

Gueltig bis {new Date(lastInvite.expires_at).toLocaleString('de-DE')}

+
+ )} + + {loading ? ( +
Laedt…
+ ) : items.length === 0 ? ( +
Keine eingeladenen Eltern.
+ ) : ( + + + + + + + + + + + + {items.map(it => ( + + + + + + + + ))} + +
E-MailKindKlasseSprache
{it.email}{it.child_first_name} {it.child_last_name}{it.class_name}{it.preferred_language} + +
+ )} +
+ ) +} diff --git a/studio-v2/app/schulkalender/page.tsx b/studio-v2/app/schulkalender/page.tsx index e59bac6..0a3e2fe 100644 --- a/studio-v2/app/schulkalender/page.tsx +++ b/studio-v2/app/schulkalender/page.tsx @@ -13,6 +13,7 @@ import { BundeslandWizard } from './_components/BundeslandWizard' import { EventModal } from './_components/EventModal' import { DayDetail } from './_components/DayDetail' import { RolloverWizard } from './_components/RolloverWizard' +import { ParentManager } from './_components/ParentManager' function monthRange(year: number, month: number): { from: string; to: string } { // Render the visible 6-week grid worth of holidays (covers prev/next month edges). @@ -168,6 +169,10 @@ export default function SchulkalenderPage() { onDone={() => { setShowRollover(false); loadHolidays() }} /> )} + +
+ +
)} diff --git a/studio-v2/app/schulkalender/types.ts b/studio-v2/app/schulkalender/types.ts index a0c4a66..b9cbfe7 100644 --- a/studio-v2/app/schulkalender/types.ts +++ b/studio-v2/app/schulkalender/types.ts @@ -114,3 +114,48 @@ export const BUNDESLAENDER: { code: string; name: string }[] = [ { code: 'DE-SH', name: 'Schleswig-Holstein' }, { code: 'DE-TH', name: 'Thueringen' }, ] + +// ---------- Parent invitations (Phase 9c) ---------- + +export interface ParentAccount { + id: string + email: string + preferred_language: string +} + +export interface ParentChild { + id: string + parent_id: string + tt_class_id: string + first_name: string + last_name: string + class_name?: string +} + +export interface ParentInviteListItem { + parent_id: string + email: string + preferred_language: string + child_id: string + child_first_name: string + child_last_name: string + class_id: string + class_name: string + created_at: string +} + +export interface InviteParentRequest { + email: string + preferred_language?: string + child_first_name: string + child_last_name: string + tt_class_id: string +} + +export interface InviteParentResponse { + parent: ParentAccount + child: ParentChild + magic_token: string + magic_url: string + expires_at: string +} diff --git a/studio-v2/e2e/eltern.spec.ts b/studio-v2/e2e/eltern.spec.ts new file mode 100644 index 0000000..2d0566e --- /dev/null +++ b/studio-v2/e2e/eltern.spec.ts @@ -0,0 +1,134 @@ +import { test, expect, Page } from '@playwright/test' + +/** + * E2E for the Phase 9c parent-side: ParentManager on /schulkalender (teacher + * UI) and the /eltern login + timetable view. Backend calls are intercepted + * so the suite doesn't need a real teacher → parent invitation cycle. + */ + +async function mockTeacherCalendar(page: Page, opts: { classes?: unknown[]; parents?: unknown[]; invite?: unknown } = {}) { + // Existing schulkalender mocks the page already needs. + await page.route('**/api/school/calendar/config', async (route) => { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user_id: 'dev', bundesland: 'DE-NI' }) }) + }) + await page.route(/\/api\/school\/calendar\/holidays(\?.*)?$/, async (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })) + await page.route(/\/api\/school\/calendar\/events(\?.*)?$/, async (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })) + + // ParentManager loads classes via the stundenplan API. + await page.route('**/api/school/timetable/classes', async (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(opts.classes ?? []) })) + + await page.route('**/api/school/calendar/parents', async (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(opts.parents ?? []) })) + + await page.route('**/api/school/calendar/parents/invite', async (route) => { + if (route.request().method() !== 'POST') return route.fulfill({ status: 405 }) + return route.fulfill({ + status: 201, contentType: 'application/json', + body: JSON.stringify(opts.invite ?? { + parent: { id: 'p1', email: 'mama@example.de', preferred_language: 'tr' }, + child: { id: 'c1', parent_id: 'p1', tt_class_id: 'class-1', first_name: 'Max', last_name: 'Mueller' }, + magic_token: 'abc123', + magic_url: '/eltern/login?token=abc123', + expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(), + }), + }) + }) +} + +test.describe('Schulkalender — ParentManager', () => { + test('renders empty state when no parents invited', async ({ page }) => { + await mockTeacherCalendar(page, { classes: [{ id: 'class-1', name: '5a', grade_level: 5, student_count: 24, created_by_user_id: 'u', created_at: '' }] }) + await page.goto('/schulkalender') + await page.waitForLoadState('networkidle') + const manager = page.getByTestId('parent-manager') + await expect(manager).toBeVisible() + await expect(manager.getByText('Keine eingeladenen Eltern.')).toBeVisible() + }) + + test('+ Eltern einladen opens the form when classes exist', async ({ page }) => { + await mockTeacherCalendar(page, { classes: [{ id: 'class-1', name: '5a', grade_level: 5, student_count: 24, created_by_user_id: 'u', created_at: '' }] }) + await page.goto('/schulkalender') + await page.waitForLoadState('networkidle') + await page.getByTestId('parent-invite-toggle').click() + await expect(page.getByTestId('parent-email')).toBeVisible() + }) + + test('submitting invite shows the magic link to copy', async ({ page }) => { + await mockTeacherCalendar(page, { classes: [{ id: 'class-1', name: '5a', grade_level: 5, student_count: 24, created_by_user_id: 'u', created_at: '' }] }) + await page.goto('/schulkalender') + await page.waitForLoadState('networkidle') + await page.getByTestId('parent-invite-toggle').click() + await page.getByTestId('parent-email').fill('mama@example.de') + await page.getByTestId('parent-child-first').fill('Max') + await page.getByTestId('parent-child-last').fill('Mueller') + await page.getByTestId('parent-class').selectOption('class-1') + await page.getByTestId('parent-invite-submit').click() + await expect(page.getByTestId('parent-invite-link')).toBeVisible() + await expect(page.getByText('Einladungs-Link fuer mama@example.de')).toBeVisible() + }) +}) + +async function mockParentApi(page: Page, opts: { redeemOk?: boolean; me?: unknown; lessons?: unknown[] } = {}) { + const redeemOk = opts.redeemOk ?? true + await page.route('**/api/parent/auth/redeem', async (route) => { + if (!redeemOk) return route.fulfill({ status: 401, contentType: 'application/json', body: '{"error":"invalid"}' }) + return route.fulfill({ + status: 200, contentType: 'application/json', + headers: { 'set-cookie': 'bp_parent_session=test; Path=/; HttpOnly' }, + body: JSON.stringify({ id: 'p1', email: 'mama@example.de', preferred_language: 'tr' }), + }) + }) + await page.route('**/api/parent/me', async (route) => { + return route.fulfill({ + status: 200, contentType: 'application/json', + body: JSON.stringify(opts.me ?? { + parent: { id: 'p1', email: 'mama@example.de', preferred_language: 'tr' }, + children: [{ id: 'c1', parent_id: 'p1', tt_class_id: 'class-1', first_name: 'Max', last_name: 'Mueller', class_name: '5a' }], + }), + }) + }) + await page.route(/\/api\/parent\/me\/timetable(\?.*)?$/, async (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(opts.lessons ?? []) })) + await page.route('**/api/parent/auth/logout', async (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"ok"}' })) +} + +test.describe('Eltern — Login + Wochengrid', () => { + test('login page shows error when no token in URL', async ({ page }) => { + await mockParentApi(page) + await page.goto('/eltern/login') + await expect(page.getByTestId('eltern-login')).toBeVisible() + await expect(page.getByText('Kein Token in der URL')).toBeVisible() + }) + + test('valid token redirects to the parent overview', async ({ page }) => { + await mockParentApi(page, {}) + await page.goto('/eltern/login?token=abc123') + await page.waitForURL('**/eltern', { timeout: 3000 }) + await expect(page.getByTestId('eltern-page')).toBeVisible() + }) + + test('shows greeting and child class on /eltern', async ({ page }) => { + await mockParentApi(page) + await page.goto('/eltern/login?token=abc123') + await page.waitForURL('**/eltern') + // Turkish greeting because preferred_language=tr. + await expect(page.getByText('Hoş geldiniz, mama@example.de')).toBeVisible() + await expect(page.getByText('Max Mueller · 5a')).toBeVisible() + }) + + test('translates subject names into the parent language', async ({ page }) => { + await mockParentApi(page, { + lessons: [ + { DayOfWeek: 1, PeriodIndex: 1, StartTime: '08:00', EndTime: '08:45', ClassName: '5a', SubjectName: 'Mathematik', SubjectCode: 'M', TeacherName: 'Schmidt, Anna', RoomName: 'A101', Pinned: false }, + ], + }) + await page.goto('/eltern/login?token=abc123') + await page.waitForURL('**/eltern') + // Turkish target = Matematik. + await expect(page.getByTestId('eltern-cell-1-1').getByText('Matematik')).toBeVisible() + }) +}) diff --git a/studio-v2/lib/calendar/subject-i18n.ts b/studio-v2/lib/calendar/subject-i18n.ts new file mode 100644 index 0000000..7d3cc82 --- /dev/null +++ b/studio-v2/lib/calendar/subject-i18n.ts @@ -0,0 +1,64 @@ +/** + * Subject-name translations for the parent-facing weekly grid. + * + * The teacher enters German subject names in tt_subject.name. For parents + * whose preferred_language differs, we look up the German name in this + * table and substitute the localised version. If no match (custom AG, + * Wahlfach, ...), the German original is shown. + * + * Keys are normalised lowercase German subject names. Languages cover the + * 8 most-common parent locales in DE schools; everything else falls back + * to German. + */ + +type SupportedLanguage = 'de' | 'en' | 'tr' | 'ar' | 'uk' | 'ru' | 'pl' | 'fr' + +interface SubjectTranslation { + de: string + en: string + tr: string + ar: string + uk: string + ru: string + pl: string + fr: string +} + +const SUBJECTS: Record = { + mathematik: { de: 'Mathematik', en: 'Mathematics', tr: 'Matematik', ar: 'الرياضيات', uk: 'Математика', ru: 'Математика', pl: 'Matematyka', fr: 'Mathématiques' }, + mathe: { de: 'Mathe', en: 'Maths', tr: 'Matematik', ar: 'الرياضيات', uk: 'Математика', ru: 'Математика', pl: 'Matematyka', fr: 'Maths' }, + deutsch: { de: 'Deutsch', en: 'German', tr: 'Almanca', ar: 'الألمانية', uk: 'Німецька мова', ru: 'Немецкий язык', pl: 'Język niemiecki', fr: 'Allemand' }, + englisch: { de: 'Englisch', en: 'English', tr: 'İngilizce', ar: 'الإنجليزية', uk: 'Англійська мова', ru: 'Английский язык', pl: 'Język angielski', fr: 'Anglais' }, + franzoesisch: { de: 'Franzoesisch', en: 'French', tr: 'Fransızca', ar: 'الفرنسية', uk: 'Французька мова', ru: 'Французский язык', pl: 'Język francuski', fr: 'Français' }, + spanisch: { de: 'Spanisch', en: 'Spanish', tr: 'İspanyolca', ar: 'الإسبانية', uk: 'Іспанська мова', ru: 'Испанский язык', pl: 'Język hiszpański', fr: 'Espagnol' }, + latein: { de: 'Latein', en: 'Latin', tr: 'Latince', ar: 'اللاتينية', uk: 'Латинська мова', ru: 'Латинский язык', pl: 'Łacina', fr: 'Latin' }, + sachkunde: { de: 'Sachkunde', en: 'General Studies', tr: 'Hayat Bilgisi', ar: 'الدراسات العامة', uk: 'Природознавство', ru: 'Окружающий мир', pl: 'Wiedza o przyrodzie', fr: 'Découverte du monde' }, + sport: { de: 'Sport', en: 'PE', tr: 'Beden Eğitimi', ar: 'التربية البدنية', uk: 'Фізкультура', ru: 'Физкультура', pl: 'WF', fr: 'EPS' }, + musik: { de: 'Musik', en: 'Music', tr: 'Müzik', ar: 'الموسيقى', uk: 'Музика', ru: 'Музыка', pl: 'Muzyka', fr: 'Musique' }, + kunst: { de: 'Kunst', en: 'Art', tr: 'Sanat', ar: 'الفن', uk: 'Мистецтво', ru: 'Искусство', pl: 'Plastyka', fr: 'Arts plastiques' }, + religion: { de: 'Religion', en: 'Religion', tr: 'Din Bilgisi', ar: 'الدين', uk: 'Релігія', ru: 'Религия', pl: 'Religia', fr: 'Religion' }, + ethik: { de: 'Ethik', en: 'Ethics', tr: 'Etik', ar: 'الأخلاق', uk: 'Етика', ru: 'Этика', pl: 'Etyka', fr: 'Éthique' }, + biologie: { de: 'Biologie', en: 'Biology', tr: 'Biyoloji', ar: 'الأحياء', uk: 'Біологія', ru: 'Биология', pl: 'Biologia', fr: 'Biologie' }, + chemie: { de: 'Chemie', en: 'Chemistry', tr: 'Kimya', ar: 'الكيمياء', uk: 'Хімія', ru: 'Химия', pl: 'Chemia', fr: 'Chimie' }, + physik: { de: 'Physik', en: 'Physics', tr: 'Fizik', ar: 'الفيزياء', uk: 'Фізика', ru: 'Физика', pl: 'Fizyka', fr: 'Physique' }, + geschichte: { de: 'Geschichte', en: 'History', tr: 'Tarih', ar: 'التاريخ', uk: 'Історія', ru: 'История', pl: 'Historia', fr: 'Histoire' }, + geografie: { de: 'Geografie', en: 'Geography', tr: 'Coğrafya', ar: 'الجغرافيا', uk: 'Географія', ru: 'География', pl: 'Geografia', fr: 'Géographie' }, + erdkunde: { de: 'Erdkunde', en: 'Geography', tr: 'Coğrafya', ar: 'الجغرافيا', uk: 'Географія', ru: 'География', pl: 'Geografia', fr: 'Géographie' }, + politik: { de: 'Politik', en: 'Civics', tr: 'Vatandaşlık', ar: 'التربية الوطنية', uk: 'Громадянознавство', ru: 'Обществознание', pl: 'Wiedza o społeczeństwie', fr: 'Éducation civique' }, + informatik: { de: 'Informatik', en: 'Computer Science', tr: 'Bilişim', ar: 'علوم الحاسوب', uk: 'Інформатика', ru: 'Информатика', pl: 'Informatyka', fr: 'Informatique' }, + wirtschaft: { de: 'Wirtschaft', en: 'Economics', tr: 'Ekonomi', ar: 'الاقتصاد', uk: 'Економіка', ru: 'Экономика', pl: 'Ekonomia', fr: 'Économie' }, +} + +/** + * Translate a German subject name into the requested language. + * Falls back to the original input if no match in the table or no + * translation for the target language. + */ +export function translateSubject(germanName: string, lang: string): string { + if (!germanName) return germanName + const key = germanName.toLowerCase().trim() + const row = SUBJECTS[key] + if (!row) return germanName + const code = (lang || 'de').slice(0, 2) as SupportedLanguage + return row[code] || row.de || germanName +} diff --git a/studio-v2/lib/eltern/api.ts b/studio-v2/lib/eltern/api.ts new file mode 100644 index 0000000..b80f221 --- /dev/null +++ b/studio-v2/lib/eltern/api.ts @@ -0,0 +1,64 @@ +/** + * Parent API client. Cookies (HttpOnly bp_parent_session) carry auth — + * we never store the session token in JS-readable storage. credentials: + * 'include' is mandatory so the cookie ships with each request. + */ + +const PROXY_PREFIX = '/api/parent' + +interface FetchOptions extends RequestInit { + expectJson?: boolean +} + +async function parentFetch(endpoint: string, opts: FetchOptions = {}): Promise { + const res = await fetch(`${PROXY_PREFIX}${endpoint}`, { + ...opts, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...(opts.headers as Record | undefined), + }, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Unknown error' })) + throw new Error(err.error || `HTTP ${res.status}`) + } + if (res.status === 204) return undefined as T + return res.json() +} + +export interface ParentMeResponse { + parent: { id: string; email: string; preferred_language: string } + children: Array<{ + id: string + parent_id: string + tt_class_id: string + first_name: string + last_name: string + class_name?: string + }> +} + +export interface ParentLesson { + DayOfWeek: number + PeriodIndex: number + StartTime: string + EndTime: string + ClassName: string + SubjectName: string + SubjectCode: string + TeacherName: string + RoomName: string + Pinned: boolean +} + +export const elternApi = { + redeem: (token: string) => + parentFetch<{ id: string; email: string; preferred_language: string }>('/auth/redeem', { + method: 'POST', body: JSON.stringify({ token }), + }), + me: () => parentFetch('/me'), + timetable: (classId: string) => + parentFetch(`/me/timetable?class_id=${encodeURIComponent(classId)}`), + logout: () => parentFetch('/auth/logout', { method: 'POST' }), +} diff --git a/studio-v2/lib/schulkalender/api.ts b/studio-v2/lib/schulkalender/api.ts index 25b8551..1c54bbf 100644 --- a/studio-v2/lib/schulkalender/api.ts +++ b/studio-v2/lib/schulkalender/api.ts @@ -7,6 +7,7 @@ import { getStundenplanToken } from '@/lib/stundenplan/api' import type { PublicEvent, SchoolCalendarConfig, UpsertSchoolCalendarConfig, SchoolEvent, CreateSchoolEvent, SchoolYearRolloverResult, + ParentInviteListItem, InviteParentRequest, InviteParentResponse, } from '@/app/schulkalender/types' async function apiFetch(endpoint: string, options: RequestInit = {}): Promise { @@ -49,4 +50,11 @@ export const calendarApi = { new_year_end: newYearEnd, }), }), + + // Phase 9c: parent invitations + listParents: () => apiFetch('/calendar/parents'), + inviteParent: (data: InviteParentRequest) => + apiFetch('/calendar/parents/invite', { method: 'POST', body: JSON.stringify(data) }), + deleteParentChild: (childId: string) => + apiFetch(`/calendar/parents/children/${childId}`, { method: 'DELETE' }), }