Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes. Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending. Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete. Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
557 lines
19 KiB
Go
557 lines
19 KiB
Go
package store
|
||
|
||
import (
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// ---- 项目与会话(租户域:owner 过滤是硬边界,跨租户一律 NotFound)----
|
||
|
||
func (s *Store) CreateProject(owner, name string) (core.Project, error) {
|
||
p := core.Project{ID: newID("prj"), Owner: owner, Name: name, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), Settings: map[string]any{}}
|
||
_, err := s.db.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`),
|
||
p.ID, owner, p.Name, "{}", nowISO(), nowISO())
|
||
return p, err
|
||
}
|
||
|
||
func (s *Store) ListProjects(owner string) ([]core.Project, error) {
|
||
rows, err := s.db.Query(s.q(`SELECT id,name,settings,created_at,updated_at FROM projects WHERE owner=? ORDER BY updated_at DESC`), owner)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
return scanProjects(rows)
|
||
}
|
||
|
||
// ListAllProjects 全量项目(含他人 owner;EXT-15)。仅供服务端在共享适配器
|
||
// 已注册时枚举候选——调用方须逐项目过适配器,不得直接下发全表。
|
||
func (s *Store) ListAllProjects() ([]core.Project, error) {
|
||
rows, err := s.db.Query(s.q(`SELECT id,owner,name,settings,created_at,updated_at FROM projects ORDER BY updated_at DESC`))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []core.Project{}
|
||
for rows.Next() {
|
||
var p core.Project
|
||
var settings, ca, ua string
|
||
if err := rows.Scan(&p.ID, &p.Owner, &p.Name, &settings, &ca, &ua); err != nil {
|
||
return nil, err
|
||
}
|
||
p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
|
||
p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
|
||
if settings != "" && settings != "{}" {
|
||
p.Settings, _ = unmarshalJSON[map[string]any](settings)
|
||
}
|
||
out = append(out, p)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *Store) GetProject(owner, id string) (core.Project, error) {
|
||
var p core.Project
|
||
var settings, ca, ua string
|
||
err := s.db.QueryRow(s.q(`SELECT id,name,settings,created_at,updated_at FROM projects WHERE id=? AND owner=?`), id, owner).
|
||
Scan(&p.ID, &p.Name, &settings, &ca, &ua)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return p, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return p, err
|
||
}
|
||
p.Owner = owner
|
||
p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
|
||
p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
|
||
if settings != "" && settings != "{}" {
|
||
p.Settings, _ = unmarshalJSON[map[string]any](settings)
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
// UpdateProjectSettings 读改写走事务 + scope 锁:并发合并不再丢失更新。
|
||
func (s *Store) UpdateProjectSettings(owner, id string, values map[string]any) error {
|
||
tx, err := s.db.Begin()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer tx.Rollback()
|
||
if err := s.dialect.lockScope(tx, owner, "project:"+id); err != nil {
|
||
return err
|
||
}
|
||
var settings string
|
||
err = tx.QueryRow(s.q(`SELECT settings FROM projects WHERE id=? AND owner=?`), id, owner).Scan(&settings)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
cur := map[string]any{}
|
||
if settings != "" && settings != "{}" {
|
||
cur, _ = unmarshalJSON[map[string]any](settings)
|
||
}
|
||
merged := core.MergeSettings(cur, values)
|
||
res, err := tx.Exec(s.q(`UPDATE projects SET settings=?, updated_at=? WHERE id=? AND owner=?`), marshalJSON(merged), nowISO(), id, owner)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
func (s *Store) RenameProject(owner, id, name string) error {
|
||
return s.updateProjectRow(owner, id, `UPDATE projects SET name=?, updated_at=? WHERE id=? AND owner=?`, name, nowISO(), id, owner)
|
||
}
|
||
|
||
func (s *Store) DeleteProject(owner, id string) error {
|
||
return s.updateProjectRow(owner, id, `DELETE FROM projects WHERE id=? AND owner=?`, id, owner)
|
||
}
|
||
|
||
func (s *Store) updateProjectRow(owner, id, query string, args ...any) error {
|
||
res, err := s.db.Exec(s.q(query), args...)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// CreateProjectAccountChecked checks the actor's effective state in the final
|
||
// transaction and makes that actor the new project's owner.
|
||
func (s *Store) CreateProjectAccountChecked(actor, name string, allowImplicitDefault bool) (core.Project, error) {
|
||
var project core.Project
|
||
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||
now := time.Now().UTC()
|
||
project = core.Project{
|
||
ID: newID("prj"), Owner: actor, Name: name,
|
||
CreatedAt: now, UpdatedAt: now, Settings: map[string]any{},
|
||
}
|
||
_, err := tx.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`),
|
||
project.ID, project.Owner, project.Name, "{}", now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
|
||
return err
|
||
})
|
||
if err != nil {
|
||
return core.Project{}, err
|
||
}
|
||
return project, nil
|
||
}
|
||
|
||
// projectForAccountWrite locks the stored project row after actor admission
|
||
// and returns ErrNotFound unless that actor owns it.
|
||
func (s *Store) projectForAccountWrite(tx *sql.Tx, actor, id string) (string, error) {
|
||
var owner, settings string
|
||
query := s.q(`SELECT owner,settings FROM projects WHERE id=?`)
|
||
if s.Dialect() == "postgres" {
|
||
query += ` FOR UPDATE`
|
||
}
|
||
err := tx.QueryRow(query, id).Scan(&owner, &settings)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return "", ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if owner != actor {
|
||
return "", ErrNotFound
|
||
}
|
||
return settings, nil
|
||
}
|
||
|
||
// RenameProjectAccountChecked keeps the legacy owner-only mutation semantics.
|
||
func (s *Store) RenameProjectAccountChecked(actor, id, name string, allowImplicitDefault bool) error {
|
||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||
if _, err := s.projectForAccountWrite(tx, actor, id); err != nil {
|
||
return err
|
||
}
|
||
res, err := tx.Exec(s.q(`UPDATE projects SET name=?,updated_at=? WHERE id=? AND owner=?`), name, nowISO(), id, actor)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if n, err := res.RowsAffected(); err != nil {
|
||
return err
|
||
} else if n != 1 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// DeleteProjectAccountChecked keeps the legacy owner-only deletion semantics.
|
||
func (s *Store) DeleteProjectAccountChecked(actor, id string, allowImplicitDefault bool) error {
|
||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||
if _, err := s.projectForAccountWrite(tx, actor, id); err != nil {
|
||
return err
|
||
}
|
||
res, err := tx.Exec(s.q(`DELETE FROM projects WHERE id=? AND owner=?`), id, actor)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if n, err := res.RowsAffected(); err != nil {
|
||
return err
|
||
} else if n != 1 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// UpdateProjectSettingsAccountChecked merges settings under the actor lock,
|
||
// then the project scope and row locks, in the final write transaction.
|
||
func (s *Store) UpdateProjectSettingsAccountChecked(actor, id string, values map[string]any, allowImplicitDefault bool) error {
|
||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||
if err := s.dialect.lockScope(tx, actor, "project:"+id); err != nil {
|
||
return err
|
||
}
|
||
settings, err := s.projectForAccountWrite(tx, actor, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
cur := map[string]any{}
|
||
if settings != "" && settings != "{}" {
|
||
cur, _ = unmarshalJSON[map[string]any](settings)
|
||
}
|
||
merged := core.MergeSettings(cur, values)
|
||
res, err := tx.Exec(s.q(`UPDATE projects SET settings=?,updated_at=? WHERE id=? AND owner=?`), marshalJSON(merged), nowISO(), id, actor)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if n, err := res.RowsAffected(); err != nil {
|
||
return err
|
||
} else if n != 1 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
func scanProjects(rows *sql.Rows) ([]core.Project, error) {
|
||
out := []core.Project{}
|
||
for rows.Next() {
|
||
var p core.Project
|
||
var settings string
|
||
var ca, ua string
|
||
if err := rows.Scan(&p.ID, &p.Name, &settings, &ca, &ua); err != nil {
|
||
return nil, err
|
||
}
|
||
p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
|
||
p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
|
||
if settings != "" && settings != "{}" {
|
||
p.Settings, _ = unmarshalJSON[map[string]any](settings)
|
||
}
|
||
out = append(out, p)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// ---- 会话 ----
|
||
|
||
func (s *Store) CreateConversation(owner, projectID, title, profileID string) (core.Conversation, error) {
|
||
// 项目归属校验:会话必须落在请求者的项目下。
|
||
var one int
|
||
err := s.db.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=? AND owner=?`), projectID, owner).Scan(&one)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return core.Conversation{}, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return core.Conversation{}, err
|
||
}
|
||
c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: owner, Title: title, ProfileID: profileID, AllowCrossRead: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
|
||
_, err = s.db.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`),
|
||
c.ID, c.ProjectID, owner, c.Title, c.ProfileID, nowISO(), nowISO())
|
||
return c, err
|
||
}
|
||
|
||
// CreateConversationInProject 在共享项目下建会话(EXT-15):只校验项目存在,
|
||
// 不校验归属(服务端已先按自有路径尝试、再经适配器放行);会话 owner 仍为创建者。
|
||
func (s *Store) CreateConversationInProject(owner, projectID, title, profileID string) (core.Conversation, error) {
|
||
var one int
|
||
err := s.db.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=?`), projectID).Scan(&one)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return core.Conversation{}, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return core.Conversation{}, err
|
||
}
|
||
c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: owner, Title: title, ProfileID: profileID, AllowCrossRead: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
|
||
_, err = s.db.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`),
|
||
c.ID, c.ProjectID, owner, c.Title, c.ProfileID, nowISO(), nowISO())
|
||
return c, err
|
||
}
|
||
|
||
func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation, error) {
|
||
rows, err := s.db.Query(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,session_note,created_at,updated_at FROM conversations WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []core.Conversation{}
|
||
for rows.Next() {
|
||
var c core.Conversation
|
||
if err := scanConversation(rows, &c); err != nil {
|
||
return nil, err
|
||
}
|
||
c.Owner = owner
|
||
out = append(out, c)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// ListTasks 跨项目任务列表(方案 A 侧栏数据源):默认仅未归档;includeArchived 含归档。
|
||
func (s *Store) ListTasks(owner string, includeArchived bool) ([]core.Conversation, error) {
|
||
q := `SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,session_note,created_at,updated_at FROM conversations WHERE owner=?`
|
||
args := []any{owner}
|
||
if !includeArchived {
|
||
q += ` AND archived=?`
|
||
args = append(args, false)
|
||
}
|
||
q += ` ORDER BY pinned DESC, updated_at DESC`
|
||
rows, err := s.db.Query(s.q(q), args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []core.Conversation{}
|
||
for rows.Next() {
|
||
var c core.Conversation
|
||
if err := scanConversation(rows, &c); err != nil {
|
||
return nil, err
|
||
}
|
||
c.Owner = owner
|
||
out = append(out, c)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func scanConversation(rows interface{ Scan(dest ...any) error }, c *core.Conversation) error {
|
||
var ca, ua string
|
||
// 直接扫 bool:PG 原生 boolean;SQLite 整数 0/1 经 driver.Bool 转换同样成立。
|
||
if err := rows.Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &c.Pinned, &c.Archived, &c.AllowCrossRead, &c.CrossPrivate, &c.PermissionMode, &c.SessionNote, &ca, &ua); err != nil {
|
||
return err
|
||
}
|
||
c.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
|
||
c.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) GetConversation(owner, id string) (core.Conversation, error) {
|
||
var c core.Conversation
|
||
var ca, ua string
|
||
err := s.db.QueryRow(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,session_note,created_at,updated_at FROM conversations WHERE id=? AND owner=?`), id, owner).
|
||
Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &c.Pinned, &c.Archived, &c.AllowCrossRead, &c.CrossPrivate, &c.PermissionMode, &c.SessionNote, &ca, &ua)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return c, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return c, err
|
||
}
|
||
c.Owner = owner
|
||
c.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
|
||
c.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
|
||
return c, nil
|
||
}
|
||
|
||
// TouchConversation 刷新会话 updated_at(内部路径;归属已在上游校验)。
|
||
func (s *Store) TouchConversation(id string) {
|
||
s.db.Exec(s.q(`UPDATE conversations SET updated_at=? WHERE id=?`), nowISO(), id)
|
||
}
|
||
|
||
func (s *Store) UpdateConversation(owner, id, title, profileID string) error {
|
||
var query string
|
||
var args []any
|
||
if title == "" {
|
||
query = `UPDATE conversations SET profile_id=?, updated_at=? WHERE id=? AND owner=?`
|
||
args = []any{profileID, nowISO(), id, owner}
|
||
} else {
|
||
query = `UPDATE conversations SET title=?, profile_id=?, updated_at=? WHERE id=? AND owner=?`
|
||
args = []any{title, profileID, nowISO(), id, owner}
|
||
}
|
||
res, err := s.db.Exec(s.q(query), args...)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SetConversationFlags 更新会话标记(nil = 该字段不动;方案 A R1 + 跨会话读取提案)。
|
||
// conversations 的四个标记列在 PG 档是 BOOLEAN——绑 Go bool,不绑 boolInt 整数。
|
||
func (s *Store) SetConversationFlags(owner, id string, pinned, archived, allowCrossRead, crossPrivate *bool) error {
|
||
var sets []string
|
||
var args []any
|
||
if pinned != nil {
|
||
sets = append(sets, "pinned=?")
|
||
args = append(args, *pinned)
|
||
}
|
||
if archived != nil {
|
||
sets = append(sets, "archived=?")
|
||
args = append(args, *archived)
|
||
}
|
||
if allowCrossRead != nil {
|
||
sets = append(sets, "allow_cross_read=?")
|
||
args = append(args, *allowCrossRead)
|
||
}
|
||
if crossPrivate != nil {
|
||
sets = append(sets, "cross_private=?")
|
||
args = append(args, *crossPrivate)
|
||
}
|
||
if len(sets) == 0 {
|
||
return nil
|
||
}
|
||
args = append(args, nowISO(), id, owner)
|
||
res, err := s.db.Exec(s.q(`UPDATE conversations SET `+strings.Join(sets, ", ")+`, updated_at=? WHERE id=? AND owner=?`), args...)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) SetPermissionMode(owner, id, mode string) error {
|
||
switch mode {
|
||
case "", "default", "plan", "auto_edit":
|
||
default:
|
||
return fmt.Errorf("store: unknown permission mode %q", mode)
|
||
}
|
||
if mode == "default" {
|
||
mode = ""
|
||
}
|
||
res, err := s.db.Exec(s.q(`UPDATE conversations SET permission_mode=?, updated_at=? WHERE id=? AND owner=?`), mode, nowISO(), id, owner)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) DeleteConversation(owner, id string) error {
|
||
return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner)
|
||
}
|
||
|
||
// withOwnedConversationWrite locks the actor before the conversation and
|
||
// resolves ownership from the stored row in the final write transaction.
|
||
func (s *Store) withOwnedConversationWrite(actor, id string, allowImplicitDefault bool, write func(*sql.Tx) error) error {
|
||
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||
if err := s.dialect.lockConversation(tx, id); err != nil {
|
||
return err
|
||
}
|
||
query := s.q(`SELECT owner FROM conversations WHERE id=?`)
|
||
if s.Dialect() == "postgres" {
|
||
query += ` FOR UPDATE`
|
||
}
|
||
var owner string
|
||
err := tx.QueryRow(query, id).Scan(&owner)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if owner != actor {
|
||
return ErrNotFound
|
||
}
|
||
return write(tx)
|
||
})
|
||
}
|
||
|
||
func (s *Store) execOwnedConversationWrite(tx *sql.Tx, query string, args ...any) error {
|
||
res, err := tx.Exec(s.q(query), args...)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if n, err := res.RowsAffected(); err != nil {
|
||
return err
|
||
} else if n != 1 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// UpdateConversationAccountChecked changes title/profile only for its active owner.
|
||
func (s *Store) UpdateConversationAccountChecked(actor, id, title, profileID string, allowImplicitDefault bool) error {
|
||
return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error {
|
||
if title == "" {
|
||
return s.execOwnedConversationWrite(tx,
|
||
`UPDATE conversations SET profile_id=?,updated_at=? WHERE id=? AND owner=?`,
|
||
profileID, nowISO(), id, actor)
|
||
}
|
||
return s.execOwnedConversationWrite(tx,
|
||
`UPDATE conversations SET title=?,profile_id=?,updated_at=? WHERE id=? AND owner=?`,
|
||
title, profileID, nowISO(), id, actor)
|
||
})
|
||
}
|
||
|
||
// DeleteConversationAccountChecked retains the legacy owner-only delete rule.
|
||
func (s *Store) DeleteConversationAccountChecked(actor, id string, allowImplicitDefault bool) error {
|
||
return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error {
|
||
return s.execOwnedConversationWrite(tx, `DELETE FROM conversations WHERE id=? AND owner=?`, id, actor)
|
||
})
|
||
}
|
||
|
||
// SetConversationFlagsAccountChecked applies the legacy partial flag update
|
||
// while checking the actor and owner even when all flags are nil.
|
||
func (s *Store) SetConversationFlagsAccountChecked(actor, id string, pinned, archived, allowCrossRead, crossPrivate *bool, allowImplicitDefault bool) error {
|
||
var sets []string
|
||
var args []any
|
||
for _, field := range []struct {
|
||
name string
|
||
value *bool
|
||
}{
|
||
{"pinned", pinned}, {"archived", archived},
|
||
{"allow_cross_read", allowCrossRead}, {"cross_private", crossPrivate},
|
||
} {
|
||
if field.value != nil {
|
||
sets = append(sets, field.name+"=?")
|
||
args = append(args, *field.value)
|
||
}
|
||
}
|
||
return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error {
|
||
if len(sets) == 0 {
|
||
return nil
|
||
}
|
||
return s.execOwnedConversationWrite(tx,
|
||
`UPDATE conversations SET `+strings.Join(sets, ", ")+`,updated_at=? WHERE id=? AND owner=?`,
|
||
append(args, nowISO(), id, actor)...)
|
||
})
|
||
}
|
||
|
||
// SetPermissionModeAccountChecked validates and stores the legacy mode values.
|
||
func (s *Store) SetPermissionModeAccountChecked(actor, id, mode string, allowImplicitDefault bool) error {
|
||
switch mode {
|
||
case "", "default", "plan", "auto_edit":
|
||
default:
|
||
return fmt.Errorf("store: unknown permission mode %q", mode)
|
||
}
|
||
if mode == "default" {
|
||
mode = ""
|
||
}
|
||
return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error {
|
||
return s.execOwnedConversationWrite(tx,
|
||
`UPDATE conversations SET permission_mode=?,updated_at=? WHERE id=? AND owner=?`,
|
||
mode, nowISO(), id, actor)
|
||
})
|
||
}
|
||
|
||
// SetSessionNoteAccountChecked stores the note for the active conversation owner.
|
||
func (s *Store) SetSessionNoteAccountChecked(actor, id, note string, allowImplicitDefault bool) error {
|
||
return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error {
|
||
return s.execOwnedConversationWrite(tx,
|
||
`UPDATE conversations SET session_note=?,updated_at=? WHERE id=? AND owner=?`,
|
||
note, nowISO(), id, actor)
|
||
})
|
||
}
|