package pocketbase import ( "fmt" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/router" "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" ) // GuardOwnerDeletion отвергает удаление учётной записи, у которой остались // задачи расшифровки. // // Колонка владельца — связь с выключенным каскадным удалением, и одного этого // мало: при выключенном каскаде хранилище не удаляет ссылающуюся запись, а // **вынимает** идентификатор из поля связи и сохраняет её без проверок. Задачи // остались бы на месте, но стали бы ничьими, а ничья задача не достаётся по API // никому — архив человека исчез бы молча и восстановлению не подлежал: // прежнего владельца не остаётся нигде. // // Цена запрета названа прямо: владелец панели упирается в отказ, а способа // удалить записи в сервисе пока нет вовсе — его приносит отдельная задача. До // неё удаление учётной записи с записями невозможно, и это осознанный тупик. // // Слой стоит на удалении записи, а не на запросе к панели: панель ходит правами // суперпользователя, и правило коллекции её не судит. Удаление при этом не // только панельное — умолчание библиотеки разрешает вошедшему удалить свою // учётную запись запросом, так что страж закрывает и публичную поверхность. // // Считаются **обе** коллекции с владельцем. Файл переживает свою задачу: шаг // конвейера заводит его до сохранения задачи, и потерянный захват оставляет файл // с владельцем и без ссылки. Учётная запись, у которой остались одни такие // файлы, без этого счёта удалялась бы штатно, а аудио становилось бы ничьим. func GuardOwnerDeletion(app core.App) { app.OnRecordDelete(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error { count, err := countOwned(e.App, e.Record.Id) if err != nil { return err } if count > 0 { // Отказ отдаётся ошибкой роутера, а не обычной: библиотека пропускает // наружу только `*router.ApiError`, а всякую другую подменяет своим // сообщением — «убедитесь, что запись не участвует в обязательной // связи». Подсказка эта не просто бесполезная, а **ведущая**: // единственная обязательная связь у задачи — файл, и владелец панели, // поверив ей, пойдёт удалять задачи и файлы руками. То есть сделает // ровно то необратимое, ради предотвращения чего страж и заведён. // // Число в отказе — не содержимое записей, а их счёт: он говорит // владельцу панели, почему удаление не прошло, и не выносит наружу // ничего о самих записях. return router.NewBadRequestError(fmt.Sprintf( "у учётной записи остались записи (%d): сервис — архив, и удаление сделало бы их ничьими", count, ), nil) } return e.Next() }) } // countOwned считает всё, что принадлежит учётной записи, — по обеим коллекциям // с колонкой владельца. Перечень живёт здесь одним списком: разойдясь с шагом // схемы, он оставил бы половину архива без защиты молча. func countOwned(app core.App, ownerID string) (int64, error) { var total int64 for _, collection := range []string{migrations.JobsCollection, migrations.FilesCollection} { count, err := app.CountRecords(collection, dbx.HashExp{"owner": ownerID}) if err != nil { return 0, fmt.Errorf("failed to count owned records in %s: %w", collection, err) } total += count } return total, nil }