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 }