Files
silo-server/internal/historyimport/repo_admin.go
T

439 lines
14 KiB
Go

package historyimport
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
var (
ErrMappingNotFound = errors.New("history import user mapping not found")
ErrMappingDuplicate = errors.New("a mapping for this external user already exists on this source")
ErrNoAdminToken = errors.New("history import source has no admin token configured")
ErrActiveRunExists = errors.New("an import run for this mapping is already active")
)
// --- Source admin token ---
// SetSourceAdminToken stores an admin token for the given source.
func (r *Repository) SetSourceAdminToken(ctx context.Context, sourceID int, token string) error {
result, err := r.pool.Exec(ctx, `
UPDATE history_import_sources
SET admin_token = $2, updated_at = NOW()
WHERE id = $1`, sourceID, token)
if err != nil {
return fmt.Errorf("setting admin token for source %d: %w", sourceID, err)
}
if result.RowsAffected() == 0 {
return ErrSourceNotFound
}
return nil
}
// ClearSourceAdminToken removes the stored admin token from the given source.
func (r *Repository) ClearSourceAdminToken(ctx context.Context, sourceID int) error {
result, err := r.pool.Exec(ctx, `
UPDATE history_import_sources
SET admin_token = NULL, updated_at = NOW()
WHERE id = $1`, sourceID)
if err != nil {
return fmt.Errorf("clearing admin token for source %d: %w", sourceID, err)
}
if result.RowsAffected() == 0 {
return ErrSourceNotFound
}
return nil
}
// GetSourceWithAdminToken returns the source and its admin token.
// The raw token is for internal service use only and must not be returned in API responses.
func (r *Repository) GetSourceWithAdminToken(ctx context.Context, sourceID int) (*Source, string, error) {
row := r.pool.QueryRow(ctx, `
SELECT id, name, source_type, base_url, COALESCE(system_id, ''), enabled, sort_order,
(admin_token IS NOT NULL) AS has_admin_token,
COALESCE(admin_token, ''),
created_at, updated_at
FROM history_import_sources
WHERE id = $1`, sourceID)
var s Source
var adminToken string
err := row.Scan(
&s.ID, &s.Name, &s.SourceType, &s.BaseURL, &s.SystemID,
&s.Enabled, &s.SortOrder, &s.HasAdminToken, &adminToken,
&s.CreatedAt, &s.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", ErrSourceNotFound
}
if err != nil {
return nil, "", fmt.Errorf("getting source %d with admin token: %w", sourceID, err)
}
return &s, adminToken, nil
}
// --- Mapping CRUD ---
func (r *Repository) CreateMapping(ctx context.Context, input CreateMappingInput) (*UserMapping, error) {
row := r.pool.QueryRow(ctx, `
INSERT INTO history_import_user_mappings
(source_id, external_user_id, external_user_name, silo_user_id, silo_profile_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, source_id, external_user_id, external_user_name,
silo_user_id, silo_profile_id, last_imported_at,
created_at, updated_at`,
input.SourceID, input.ExternalUserID, input.ExternalUserName,
input.SiloUserID, input.SiloProfileID,
)
m, err := scanMapping(row)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return nil, ErrMappingDuplicate
}
return nil, fmt.Errorf("creating history import mapping: %w", err)
}
return enrichMapping(ctx, r, m)
}
func (r *Repository) ListMappingsForSource(ctx context.Context, sourceID int) ([]UserMapping, error) {
rows, err := r.pool.Query(ctx, `
SELECT m.id, m.source_id, m.external_user_id, m.external_user_name,
m.silo_user_id, m.silo_profile_id, m.last_imported_at,
m.created_at, m.updated_at
FROM history_import_user_mappings m
WHERE m.source_id = $1
ORDER BY m.external_user_name ASC, m.id ASC`, sourceID)
if err != nil {
return nil, fmt.Errorf("listing mappings for source %d: %w", sourceID, err)
}
defer rows.Close()
var mappings []UserMapping
for rows.Next() {
m, err := scanMappingRow(rows)
if err != nil {
return nil, err
}
mappings = append(mappings, *m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating mappings: %w", err)
}
// Enrich with silo user/profile names.
for i := range mappings {
if enriched, err := enrichMapping(ctx, r, &mappings[i]); err == nil {
mappings[i] = *enriched
}
}
return mappings, nil
}
// ListMappingsForBulkRun returns mappings for a source that do NOT currently have an active run.
func (r *Repository) ListMappingsForBulkRun(ctx context.Context, sourceID int) ([]UserMapping, error) {
rows, err := r.pool.Query(ctx, `
SELECT m.id, m.source_id, m.external_user_id, m.external_user_name,
m.silo_user_id, m.silo_profile_id, m.last_imported_at,
m.created_at, m.updated_at
FROM history_import_user_mappings m
WHERE m.source_id = $1
AND NOT EXISTS (
SELECT 1 FROM history_import_runs r
WHERE r.mapping_id = m.id
AND r.status IN ('queued', 'running')
)
ORDER BY m.external_user_name ASC, m.id ASC`, sourceID)
if err != nil {
return nil, fmt.Errorf("listing bulk-run mappings for source %d: %w", sourceID, err)
}
defer rows.Close()
var mappings []UserMapping
for rows.Next() {
m, err := scanMappingRow(rows)
if err != nil {
return nil, err
}
mappings = append(mappings, *m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating bulk-run mappings: %w", err)
}
return mappings, nil
}
func (r *Repository) GetMappingByID(ctx context.Context, id int) (*UserMapping, error) {
row := r.pool.QueryRow(ctx, `
SELECT id, source_id, external_user_id, external_user_name,
silo_user_id, silo_profile_id, last_imported_at,
created_at, updated_at
FROM history_import_user_mappings
WHERE id = $1`, id)
m, err := scanMapping(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrMappingNotFound
}
if err != nil {
return nil, fmt.Errorf("getting mapping %d: %w", id, err)
}
return enrichMapping(ctx, r, m)
}
func (r *Repository) UpdateMapping(ctx context.Context, id int, input UpdateMappingInput) (*UserMapping, error) {
result, err := r.pool.Exec(ctx, `
UPDATE history_import_user_mappings
SET silo_user_id = COALESCE($2, silo_user_id),
silo_profile_id = COALESCE($3, silo_profile_id),
updated_at = NOW()
WHERE id = $1`,
id, input.SiloUserID, input.SiloProfileID,
)
if err != nil {
return nil, fmt.Errorf("updating mapping %d: %w", id, err)
}
if result.RowsAffected() == 0 {
return nil, ErrMappingNotFound
}
return r.GetMappingByID(ctx, id)
}
func (r *Repository) DeleteMapping(ctx context.Context, id int) error {
result, err := r.pool.Exec(ctx, `DELETE FROM history_import_user_mappings WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("deleting mapping %d: %w", id, err)
}
if result.RowsAffected() == 0 {
return ErrMappingNotFound
}
return nil
}
// HasActiveRunForMapping reports whether a queued or running run already exists for the mapping.
func (r *Repository) HasActiveRunForMapping(ctx context.Context, mappingID int) (bool, error) {
var exists bool
err := r.pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM history_import_runs
WHERE mapping_id = $1 AND status IN ('queued', 'running')
)`, mappingID).Scan(&exists)
if err != nil {
return false, fmt.Errorf("checking active run for mapping %d: %w", mappingID, err)
}
return exists, nil
}
// TouchMappingLastImported updates last_imported_at to the current time.
func (r *Repository) TouchMappingLastImported(ctx context.Context, mappingID int) error {
_, err := r.pool.Exec(ctx, `
UPDATE history_import_user_mappings
SET last_imported_at = NOW(), updated_at = NOW()
WHERE id = $1`, mappingID)
if err != nil {
return fmt.Errorf("touching mapping %d last_imported_at: %w", mappingID, err)
}
return nil
}
// --- Admin-scoped run queries ---
// ListAllRuns returns recent runs across all users. If sourceID is non-nil, only runs
// whose mapping belongs to that source are returned.
func (r *Repository) ListAllRuns(ctx context.Context, sourceID *int, limit int) ([]Run, error) {
if limit <= 0 {
limit = 25
}
if limit > 200 {
limit = 200
}
var rows interface {
Close()
Next() bool
Scan(dest ...any) error
Err() error
}
var err error
if sourceID != nil {
rows, err = r.pool.Query(ctx, `
SELECT r.id, r.user_id, r.profile_id, r.source_type, r.connection_mode, r.status,
r.mapping_id,
r.fetched, r.matched, r.unmatched, r.progress_updated, r.history_created, r.skipped,
r.warnings, r.unmatched_samples, COALESCE(r.error_message, ''),
r.created_at, r.started_at, r.completed_at
FROM history_import_runs r
LEFT JOIN history_import_user_mappings m ON r.mapping_id = m.id
WHERE m.source_id = $1
ORDER BY r.created_at DESC
LIMIT $2`, *sourceID, limit)
} else {
rows, err = r.pool.Query(ctx, `
SELECT id, user_id, profile_id, source_type, connection_mode, status,
mapping_id,
fetched, matched, unmatched, progress_updated, history_created, skipped,
warnings, unmatched_samples, COALESCE(error_message, ''),
created_at, started_at, completed_at
FROM history_import_runs
ORDER BY created_at DESC
LIMIT $1`, limit)
}
if err != nil {
return nil, fmt.Errorf("listing all history import runs: %w", err)
}
defer rows.Close()
return scanAdminRuns(rows)
}
// GetRunByID returns any run by ID regardless of which user owns it.
func (r *Repository) GetRunByID(ctx context.Context, runID string) (*Run, error) {
row := r.pool.QueryRow(ctx, `
SELECT id, user_id, profile_id, source_type, connection_mode, status,
mapping_id,
fetched, matched, unmatched, progress_updated, history_created, skipped,
warnings, unmatched_samples, COALESCE(error_message, ''),
created_at, started_at, completed_at
FROM history_import_runs
WHERE id = $1`, runID)
run, err := scanAdminRun(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrRunNotFound
}
if err != nil {
return nil, fmt.Errorf("getting run %s: %w", runID, err)
}
return run, nil
}
func (r *Repository) ListActiveRuns(ctx context.Context, sourceID *int) ([]Run, error) {
var rows interface {
Close()
Next() bool
Scan(dest ...any) error
Err() error
}
var err error
if sourceID != nil {
rows, err = r.pool.Query(ctx, `
SELECT r.id, r.user_id, r.profile_id, r.source_type, r.connection_mode, r.status,
r.mapping_id,
r.fetched, r.matched, r.unmatched, r.progress_updated, r.history_created, r.skipped,
r.warnings, r.unmatched_samples, COALESCE(r.error_message, ''),
r.created_at, r.started_at, r.completed_at
FROM history_import_runs r
LEFT JOIN history_import_user_mappings m ON r.mapping_id = m.id
WHERE m.source_id = $1
AND r.status IN ($2, $3)
ORDER BY r.created_at DESC`, *sourceID, RunStatusQueued, RunStatusRunning)
} else {
rows, err = r.pool.Query(ctx, `
SELECT id, user_id, profile_id, source_type, connection_mode, status,
mapping_id,
fetched, matched, unmatched, progress_updated, history_created, skipped,
warnings, unmatched_samples, COALESCE(error_message, ''),
created_at, started_at, completed_at
FROM history_import_runs
WHERE status IN ($1, $2)
ORDER BY created_at DESC`, RunStatusQueued, RunStatusRunning)
}
if err != nil {
return nil, fmt.Errorf("listing active history import runs: %w", err)
}
defer rows.Close()
return scanAdminRuns(rows)
}
// CancelRunIfActive sets the run to failed status if it is currently queued or running.
// Returns ErrRunNotFound if the run doesn't exist or is already in a terminal state.
func (r *Repository) CancelRunIfActive(ctx context.Context, runID string) error {
result, err := r.pool.Exec(ctx, `
UPDATE history_import_runs
SET status = 'cancelled',
error_message = 'Cancelled by admin',
completed_at = NOW()
WHERE id = $1 AND status IN ('queued', 'running')`, runID)
if err != nil {
return fmt.Errorf("cancelling run %s: %w", runID, err)
}
if result.RowsAffected() == 0 {
return ErrRunNotFound
}
return nil
}
// --- Scan helpers ---
type mappingScanner interface {
Scan(dest ...any) error
}
func scanMapping(s mappingScanner) (*UserMapping, error) {
var m UserMapping
var lastImportedAt *time.Time
err := s.Scan(
&m.ID, &m.SourceID, &m.ExternalUserID, &m.ExternalUserName,
&m.SiloUserID, &m.SiloProfileID, &lastImportedAt,
&m.CreatedAt, &m.UpdatedAt,
)
if err != nil {
return nil, err
}
m.LastImportedAt = lastImportedAt
return &m, nil
}
type mappingRowScanner interface {
Scan(dest ...any) error
}
func scanMappingRow(s mappingRowScanner) (*UserMapping, error) {
return scanMapping(s)
}
// enrichMapping looks up the Silo username and profile name for display purposes.
func enrichMapping(ctx context.Context, r *Repository, m *UserMapping) (*UserMapping, error) {
_ = r.pool.QueryRow(ctx, `
SELECT u.username, COALESCE(p.name, '')
FROM users u
LEFT JOIN user_profiles p ON p.user_id = u.id AND p.id = $2
WHERE u.id = $1`, m.SiloUserID, m.SiloProfileID,
).Scan(&m.SiloUsername, &m.SiloProfileName)
// Non-fatal — enrichment is best-effort for display purposes.
return m, nil
}
type adminRunScanner interface {
Scan(dest ...any) error
}
func scanAdminRun(s adminRunScanner) (*Run, error) {
return scanRunWithMappingID(s)
}
type adminRunsRowSet interface {
Close()
Next() bool
Scan(dest ...any) error
Err() error
}
func scanAdminRuns(rows adminRunsRowSet) ([]Run, error) {
var runs []Run
for rows.Next() {
run, err := scanRunWithMappingID(rows)
if err != nil {
return nil, err
}
runs = append(runs, *run)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating admin runs: %w", err)
}
return runs, nil
}