package postgres import ( "crypto/rand" "crypto/sha256" "database/sql" "strings" "time" "gardomatic.kleiax.de/internal/storage" ) // GardenInviteModel stores garden invitations in PostgreSQL. // GardenInviteModel implements storage.GardenInviteModelInterface for PostgreSQL. type GardenInviteModel struct{ DB *sql.DB } // Upsert creates or replaces a pending invitation for a garden and email. func (m GardenInviteModel) Upsert(invite storage.GardenInvite) (storage.GardenInvite, error) { invite.Token = rand.Text() hash := sha256.Sum256([]byte(invite.Token)) ctx, cancel := contextWithTimeout() defer cancel() err := m.DB.QueryRowContext(ctx, ` INSERT INTO garden_invites (garden_id,email,role,token_hash,invited_by,expires_at) SELECT $1,$2,$3,$4,$5,$6 WHERE EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1)) ON CONFLICT (garden_id,email) WHERE accepted_at IS NULL DO UPDATE SET role=EXCLUDED.role, token_hash=EXCLUDED.token_hash, invited_by=EXCLUDED.invited_by, expires_at=EXCLUDED.expires_at, created_at=now() RETURNING id, created_at`, invite.GardenID, invite.Email, invite.Role, hash[:], invite.InvitedBy, invite.ExpiresAt).Scan(&invite.ID, &invite.CreatedAt) return invite, err } func scanInvite(row scanner) (storage.GardenInvite, error) { var invite storage.GardenInvite err := row.Scan(&invite.ID, &invite.GardenID, &invite.Email, &invite.Role, &invite.InvitedBy, &invite.ExpiresAt, &invite.AcceptedAt, &invite.CreatedAt) return invite, err } // GetByToken returns an unexpired pending invitation by its plaintext token. func (m GardenInviteModel) GetByToken(tokenPlaintext string) (storage.GardenInvite, error) { hash := sha256.Sum256([]byte(tokenPlaintext)) ctx, cancel := contextWithTimeout() defer cancel() invite, err := scanInvite(m.DB.QueryRowContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE token_hash=$1 AND expires_at>now() AND accepted_at IS NULL`, hash[:])) if err != nil { return storage.GardenInvite{}, recordError(err) } return invite, nil } // GetAllForGarden lists pending invitations for a garden. func (m GardenInviteModel) GetAllForGarden(gardenID int) ([]storage.GardenInvite, error) { ctx, cancel := contextWithTimeout() defer cancel() rows, err := m.DB.QueryContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE garden_id=$1 AND accepted_at IS NULL ORDER BY created_at DESC`, gardenID) if err != nil { return nil, err } defer rows.Close() result := []storage.GardenInvite{} for rows.Next() { invite, err := scanInvite(rows) if err != nil { return nil, err } result = append(result, invite) } return result, rows.Err() } // Delete revokes an invitation within its garden. func (m GardenInviteModel) Delete(gardenID, inviteID int) error { ctx, cancel := contextWithTimeout() defer cancel() result, err := m.DB.ExecContext(ctx, `DELETE FROM garden_invites WHERE garden_id=$1 AND id=$2 AND accepted_at IS NULL`, gardenID, inviteID) if err != nil { return err } count, err := result.RowsAffected() if err != nil { return err } if count == 0 { return storage.ErrRecordNotFound } return nil } // Accept consumes an invitation and creates or updates membership in one // transaction so a token cannot be accepted twice concurrently. func (m GardenInviteModel) Accept(tokenPlaintext string, user storage.User) (storage.GardenMember, error) { hash := sha256.Sum256([]byte(tokenPlaintext)) ctx, cancel := contextWithTimeout() defer cancel() tx, err := m.DB.BeginTx(ctx, nil) if err != nil { return storage.GardenMember{}, err } defer tx.Rollback() invite, err := scanInvite(tx.QueryRowContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE token_hash=$1 FOR UPDATE`, hash[:])) if err != nil { return storage.GardenMember{}, recordError(err) } if invite.AcceptedAt != nil || time.Now().After(invite.ExpiresAt) { return storage.GardenMember{}, storage.ErrRecordNotFound } if !strings.EqualFold(strings.TrimSpace(invite.Email), strings.TrimSpace(user.Email)) { return storage.GardenMember{}, storage.ErrConflict } member := storage.GardenMember{GardenID: invite.GardenID, UserID: user.ID, Role: invite.Role} err = tx.QueryRowContext(ctx, `INSERT INTO garden_members (garden_id,user_id,role) VALUES ($1,$2,$3) ON CONFLICT (garden_id,user_id) DO UPDATE SET role=EXCLUDED.role RETURNING joined_at`, member.GardenID, member.UserID, member.Role).Scan(&member.JoinedAt) if err != nil { return storage.GardenMember{}, err } if _, err = tx.ExecContext(ctx, `UPDATE garden_invites SET accepted_at=now() WHERE id=$1`, invite.ID); err != nil { return storage.GardenMember{}, err } if err = tx.Commit(); err != nil { return storage.GardenMember{}, err } if err = GardenMemberModel(m).loadPermissions(&member); err != nil { return storage.GardenMember{}, err } return member, nil }