package pocketbase import ( "errors" "github.com/pocketbase/pocketbase/core" "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" "git.vakhrushev.me/av/transcriber/internal/entity" ) // BindPanelRules подчиняет правку записи в панели тем же правилам, что и правку // из кода. // // Панель — вход в запись наравне с конвейером, а не окно просмотра: ради правки // она и покупалась, остановленная запись возвращается в работу снятием признака. // Но правка полем идёт мимо кода, который чистит служебные поля, и владелец, // «вернувший запись в работу», получил бы запись с прежним признаком захвата // (захвату она не выдастся до конца срока), с числом отказов на пределе // (остановится от первого же отказа) и со старым временем входа в рубеж // (остановится снова первым же захватом по пределу простоя). Узнать об этом ему // неоткуда. // // Правило живёт **одним местом** — доменными `Resume` и `MoveToState`, — и хук // зовёт именно их, а не повторяет перечень служебных полей колонками. Повтор // перечня был бы вторым домом того же правила: новый сторож попал бы в домен и // не попал в панель, и владелец «вернул бы запись в работу», а она снова выпала // бы из выборки — молча. // // Хук стоит на правке **запросом**, а не на всяком сохранении записи. Модельное // событие не различает, кто пишет, и срабатывало бы на каждом переходе // конвейера: тогда пауза, поставленная шагом вместе со сменой рубежа, стиралась // бы тем же сохранением, а число отказов остановленной записи — которое // остановка хранит намеренно — приходило бы владельцу нулём. func BindPanelRules(app core.App) { bindProviderLoginIsImmutable(app) app.OnRecordUpdateRequest(migrations.RecordsCollection).BindFunc(func(e *core.RecordRequestEvent) error { original := e.Record.Original() if original == nil { return e.Next() } stateChanged := original.GetString("state") != e.Record.GetString("state") // Снятие признака остановки — то самое движение, ради которого признак и // заведён: запись возвращается в работу с сохранённого рубежа. resumed := !original.GetDateTime("halted_at").IsZero() && e.Record.GetDateTime("halted_at").IsZero() if !stateChanged && !resumed { return e.Next() } // Запись читается уже с правкой человека: рубеж здесь тот, который он // выбрал, а признак остановки — тот, который он снял или оставил. record := recordToAudioRecord(e.Record) switch { case resumed: record.Resume() default: record.MoveToState(record.State) } applyOwnedByPipeline(e.Record, record) if err := e.Next(); err != nil { return err } if resumed { // Перезапуск виден в журнале событий с указанием, что его сделал // человек: иначе запись, вернувшаяся в работу, выглядела бы как // запись, которая туда и не уходила. // // Строка пишется **после** сохранения: событие о правке, которая не // прошла, соврало бы о состоянии записи. Отказ записи журнала саму // правку не отменяет — журнал никем не читается ради решения. event := &entity.RecordEvent{ RecordID: e.Record.Id, Origin: entity.EventOriginHuman, Step: "resume", Outcome: entity.EventOutcomeResumed, } if err := NewRecordEventRepository(e.App).Append(event); err != nil { e.App.Logger().Error("Failed to log record resume", "error", err, "record_id", e.Record.Id) } } return nil }) } // bindProviderLoginIsImmutable запрещает менять ключ учётной записи после // заведения. // // Ключ — логин человека у провайдера, и по нему сервис узнаёт пришедшего. // Переписанный, он отдаёт весь архив прежнего владельца следующему, кто придёт // с этим именем: владелец записи назначается один раз и не меняется, так что // вернуть архив будет нечем. Молча — журнала событий у коллекции пользователей // нет. // // Правила доступа коллекции закрывают этот путь **снаружи**, но не изнутри: // панель работает суперпользователем и правила обходит по построению. Отсюда // хук, и он вешается на **модельное** событие, а не на правку запросом — иначе // панель осталась бы незакрытой, а закрывать её и есть весь смысл. // // Заведение проходит: событие правки на нём не срабатывает вовсе. // // Прежнее значение читается **из базы**, а не из снимка правящейся записи. // Снимок у записи, только что заведённой в этом же процессе, пуст — он не // обновляется сохранением, — и сторож, опирающийся на него, пропускал бы правку // в зависимости от того, откуда вызывающий взял запись. Панель её загружает, и // на ней сторож сработал бы; молчаливая же зависимость от способа получения — // ровно тот класс, из-за которого правило и заводится. func bindProviderLoginIsImmutable(app core.App) { app.OnRecordUpdate(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error { stored, err := e.App.FindRecordById(migrations.UsersCollection, e.Record.Id) if err != nil { // Записи в базе нет — правки тоже нет: сохранение отвергнется само. return e.Next() } was := stored.GetString(migrations.ProviderLoginField) now := e.Record.GetString(migrations.ProviderLoginField) if was != "" && was != now { return errors.New("provider login is assigned once and never changes") } return e.Next() }) }