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 }