Files
Gardomatic/internal/storage/postgres/garden_invites.go
T
kleiax 904d14b64c
CI / test (push) Canceled after 0s
Initial commit
2026-09-12 22:22:17 +02:00

127 lines
4.9 KiB
Go

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
}