An imported account with a wrong password could only be deleted and re-added, which loses its folder mapping and its migration journal. Clicking either FAIL badge now opens a dialog for both logins and both passwords. Passwords are never sent to the browser, so the password fields start empty and an empty field keeps the stored ciphertext — one side can be corrected without retyping the other. Saving resets both test verdicts to unknown: they described the previous credentials, and the run gate requires a passing test on both sides, so the account cannot start on an unverified password. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
133 lines
4.7 KiB
Go
133 lines
4.7 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
)
|
|
|
|
type Account struct {
|
|
ID int64
|
|
TaskID int64
|
|
SrcLogin string
|
|
SrcPassEnc string
|
|
DstLogin string
|
|
DstPassEnc string
|
|
TestSrcStatus string
|
|
TestDstStatus string
|
|
Status string
|
|
Copied int64
|
|
Skipped int64
|
|
Errors int64
|
|
LastError string
|
|
FolderMapping map[string]string
|
|
ExcludedFolders []string
|
|
}
|
|
|
|
func (s *Store) CreateAccount(ctx context.Context, a Account) (int64, error) {
|
|
var id int64
|
|
err := s.Pool.QueryRow(ctx,
|
|
`INSERT INTO accounts (task_id, src_login, src_pass_enc, dst_login, dst_pass_enc)
|
|
VALUES ($1,$2,$3,$4,$5) RETURNING id`,
|
|
a.TaskID, a.SrcLogin, a.SrcPassEnc, a.DstLogin, a.DstPassEnc).Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
// UpdateAccountCredentials replaces an account's logins and, when a new
|
|
// ciphertext is supplied, its passwords; a nil password keeps the stored one so
|
|
// the operator can fix only the side that failed. Both connection tests are
|
|
// reset to "unknown" because the previous verdicts no longer describe these
|
|
// credentials, which also forces a re-test before the account can run.
|
|
func (s *Store) UpdateAccountCredentials(ctx context.Context, id int64, srcLogin, dstLogin string, srcPassEnc, dstPassEnc *string) error {
|
|
_, err := s.Pool.Exec(ctx,
|
|
`UPDATE accounts SET src_login=$2, dst_login=$3,
|
|
src_pass_enc=COALESCE($4, src_pass_enc), dst_pass_enc=COALESCE($5, dst_pass_enc),
|
|
test_src_status='unknown', test_dst_status='unknown', last_error=''
|
|
WHERE id=$1`,
|
|
id, srcLogin, dstLogin, srcPassEnc, dstPassEnc)
|
|
return err
|
|
}
|
|
|
|
// DeleteAccount removes one account (and its migrated_messages via ON DELETE CASCADE).
|
|
func (s *Store) DeleteAccount(ctx context.Context, id int64) error {
|
|
_, err := s.Pool.Exec(ctx, `DELETE FROM accounts WHERE id=$1`, id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) ListAccountsByTask(ctx context.Context, taskID int64) ([]Account, error) {
|
|
rows, err := s.Pool.Query(ctx,
|
|
`SELECT id, task_id, src_login, src_pass_enc, dst_login, dst_pass_enc,
|
|
test_src_status, test_dst_status, status, copied_count, skipped_count,
|
|
error_count, last_error, folder_mapping, excluded_folders
|
|
FROM accounts WHERE task_id=$1 ORDER BY id`, taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []Account{}
|
|
for rows.Next() {
|
|
var a Account
|
|
if err := rows.Scan(&a.ID, &a.TaskID, &a.SrcLogin, &a.SrcPassEnc, &a.DstLogin, &a.DstPassEnc,
|
|
&a.TestSrcStatus, &a.TestDstStatus, &a.Status, &a.Copied, &a.Skipped, &a.Errors, &a.LastError,
|
|
&a.FolderMapping, &a.ExcludedFolders); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, a)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// SetAccountError stores (or clears, with "") the last error message shown for
|
|
// an account, so it survives a page reload after the run's live log is gone.
|
|
func (s *Store) SetAccountError(ctx context.Context, id int64, msg string) error {
|
|
_, err := s.Pool.Exec(ctx, `UPDATE accounts SET last_error=$2 WHERE id=$1`, id, msg)
|
|
return err
|
|
}
|
|
|
|
// side = "src" | "dst"
|
|
func (s *Store) SetAccountTestStatus(ctx context.Context, id int64, side, status string) error {
|
|
col := "test_src_status"
|
|
if side == "dst" {
|
|
col = "test_dst_status"
|
|
}
|
|
_, err := s.Pool.Exec(ctx, fmt.Sprintf(`UPDATE accounts SET %s=$2 WHERE id=$1`, col), id, status)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) SetAccountStatus(ctx context.Context, id int64, status string) error {
|
|
_, err := s.Pool.Exec(ctx, `UPDATE accounts SET status=$2 WHERE id=$1`, id, status)
|
|
return err
|
|
}
|
|
|
|
// ResetAccountCounters zeroes the per-account copied/skipped/error counts at the
|
|
// start of a run so a re-run reflects only the current run's totals instead of
|
|
// accumulating on top of the previous run (IncAccountCounters is additive).
|
|
func (s *Store) ResetAccountCounters(ctx context.Context, id int64) error {
|
|
_, err := s.Pool.Exec(ctx,
|
|
`UPDATE accounts SET copied_count=0, skipped_count=0, error_count=0 WHERE id=$1`, id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) IncAccountCounters(ctx context.Context, id, copied, skipped, errs int64) error {
|
|
_, err := s.Pool.Exec(ctx,
|
|
`UPDATE accounts SET copied_count=copied_count+$2,
|
|
skipped_count=skipped_count+$3, error_count=error_count+$4 WHERE id=$1`,
|
|
id, copied, skipped, errs)
|
|
return err
|
|
}
|
|
|
|
// SetAccountFolderMapping persists an account's per-folder rename map and the
|
|
// set of source folders to skip. nil is normalized to empty so JSONB stays
|
|
// '{}' / '[]' rather than null.
|
|
func (s *Store) SetAccountFolderMapping(ctx context.Context, id int64, mapping map[string]string, excluded []string) error {
|
|
if mapping == nil {
|
|
mapping = map[string]string{}
|
|
}
|
|
if excluded == nil {
|
|
excluded = []string{}
|
|
}
|
|
_, err := s.Pool.Exec(ctx,
|
|
`UPDATE accounts SET folder_mapping=$2, excluded_folders=$3 WHERE id=$1`,
|
|
id, mapping, excluded)
|
|
return err
|
|
}
|