package webevents import ( "context" "errors" "time" "data-terminal-cloud/internal/identity" "github.com/jackc/pgx/v5/pgxpool" ) var ErrCursorUnavailable = errors.New("web event cursor is unavailable") type Event struct { ID int64 `json:"id"` Type string `json:"type"` ResourceType string `json:"resource_type"` ResourceID string `json:"resource_id"` SubjectID string `json:"subject_id,omitempty"` Status string `json:"status"` OccurredAt time.Time `json:"occurred_at"` } type Service struct{ pool *pgxpool.Pool } func NewService(pool *pgxpool.Pool) *Service { return &Service{pool: pool} } func (s *Service) StartingCursor(ctx context.Context, principal identity.Principal) (int64, error) { var cursor int64 err := s.pool.QueryRow(ctx, ` SELECT COALESCE(max(we.id),0) FROM web_event we JOIN household_member hm ON hm.tenant_id=we.tenant_id AND hm.household_id=we.household_id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=we.tenant_id AND ua.status='active' WHERE ua.oidc_issuer=$1 AND ua.oidc_subject=$2 AND we.expires_at>now()`, principal.Issuer, principal.Subject, ).Scan(&cursor) return cursor, err } func (s *Service) ValidateCursor(ctx context.Context, principal identity.Principal, cursor int64) error { if cursor == 0 { return nil } var valid bool err := s.pool.QueryRow(ctx, ` SELECT EXISTS( SELECT 1 FROM web_event we JOIN household_member hm ON hm.tenant_id=we.tenant_id AND hm.household_id=we.household_id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=we.tenant_id AND ua.status='active' WHERE we.id=$1 AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND we.expires_at>now() )`, cursor, principal.Issuer, principal.Subject, ).Scan(&valid) if err != nil { return err } if !valid { return ErrCursorUnavailable } return nil } func (s *Service) ListAfter(ctx context.Context, principal identity.Principal, cursor int64, limit int) ([]Event, error) { if limit < 1 || limit > 100 { limit = 100 } rows, err := s.pool.Query(ctx, ` SELECT we.id,we.event_type,we.resource_type,we.resource_public_id::text, COALESCE(sp.public_id::text,''),we.status,we.occurred_at FROM web_event we JOIN household_member hm ON hm.tenant_id=we.tenant_id AND hm.household_id=we.household_id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=we.tenant_id AND ua.status='active' LEFT JOIN subject_profile sp ON sp.id=we.subject_id AND sp.tenant_id=we.tenant_id WHERE we.id>$1 AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND we.expires_at>now() ORDER BY we.id LIMIT $4`, cursor, principal.Issuer, principal.Subject, limit) if err != nil { return nil, err } defer rows.Close() items := make([]Event, 0) for rows.Next() { var item Event if err := rows.Scan(&item.ID, &item.Type, &item.ResourceType, &item.ResourceID, &item.SubjectID, &item.Status, &item.OccurredAt); err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func (s *Service) DeleteExpired(ctx context.Context, limit int) (int64, error) { if limit < 1 || limit > 10000 { limit = 1000 } result, err := s.pool.Exec(ctx, `DELETE FROM web_event WHERE id IN (SELECT id FROM web_event WHERE expires_at<=now() ORDER BY id LIMIT $1)`, limit) if err != nil { return 0, err } return result.RowsAffected(), nil } func (s *Service) ExpireDevicePresence(ctx context.Context, staleBefore time.Time, limit int) (int, error) { if limit < 1 || limit > 10000 { limit = 1000 } var count int err := s.pool.QueryRow(ctx, `SELECT expire_device_presence($1,$2)`, staleBefore.UTC(), limit).Scan(&count) return count, err }