439 lines
14 KiB
Go
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
|
|
}
|