package store import ( "context" "github.com/jackc/pgx/v5" ) // MapPermission — рівень доступу групи до мапи. type MapPermission struct { GroupID string `json:"group_id"` GroupName string `json:"group_name,omitempty"` Level string `json:"level"` } // MapAccess каже, що людина може робити з мапою. // // Порожній набір дозволів на мапі означає «доступна всім, хто має // maps:read»: у типовій інсталяції мапа одна на всіх, і вимагати // налаштувати доступ до неї означало б зробити типовий випадок // найдовшим. // // Порожній userID — це машинний токен, а не анонім. Токен не належить // до жодної групи, тож груповий ACL до нього не застосовний: його межі // задають scopes, які перевіряє обробник. Повертати йому «deny» означало // б зламати інтеграції першою ж налаштованою групою на мапі. func (s *Store) MapAccess(ctx context.Context, tenantID, userID, mapID string) (string, error) { if userID == "" { return "write", nil } var level string err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { return tx.QueryRow(ctx, `SELECT topo.map_access_level($1, $2)::text`, userID, mapID).Scan(&level) }) return level, err } // ListMapPermissions — хто має доступ до мапи. func (s *Store) ListMapPermissions(ctx context.Context, tenantID, mapID string) ([]MapPermission, error) { out := []MapPermission{} err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { rows, err := tx.Query(ctx, ` SELECT mp.group_id::text, g.name, mp.level::text FROM topo.map_permissions mp JOIN core.user_groups g ON g.id = mp.group_id WHERE mp.map_id = $1 ORDER BY g.name `, mapID) if err != nil { return err } defer rows.Close() for rows.Next() { var p MapPermission if err := rows.Scan(&p.GroupID, &p.GroupName, &p.Level); err != nil { return err } out = append(out, p) } return rows.Err() }) return out, err } // SetMapPermissions замінює набір доступів мапи цілком. // // Порожній список повертає мапу в стан «доступна всім». Це видно у // формі прямим текстом — мовчазне перетворення обмеженої мапи на // загальнодоступну було б найгіршим способом втратити межі. func (s *Store) SetMapPermissions(ctx context.Context, tenantID, mapID string, perms []MapPermission) error { return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { var owned bool if err := tx.QueryRow(ctx, ` SELECT EXISTS (SELECT 1 FROM topo.maps WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL) `, mapID, tenantID).Scan(&owned); err != nil { return err } if !owned { return ErrNotFound } if _, err := tx.Exec(ctx, `DELETE FROM topo.map_permissions WHERE map_id = $1`, mapID); err != nil { return err } for _, p := range perms { if p.Level == "" { p.Level = "read" } if _, err := tx.Exec(ctx, ` INSERT INTO topo.map_permissions (map_id, group_id, tenant_id, level) VALUES ($1, $2, $3, $4::core.access_level) ON CONFLICT (map_id, group_id) DO UPDATE SET level = EXCLUDED.level `, mapID, p.GroupID, tenantID, p.Level); err != nil { return err } } return nil }) } // --------------------------------------------------------------------- // Іконки // --------------------------------------------------------------------- // Icon — власна картинка для вузлів мапи. type Icon struct { ID string `json:"id"` Name string `json:"name"` Mime string `json:"mime"` Size int `json:"size_bytes"` } // ListIcons — довідник іконок без самих байтів. func (s *Store) ListIcons(ctx context.Context, tenantID string) ([]Icon, error) { out := []Icon{} err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { rows, err := tx.Query(ctx, ` SELECT id::text, name, mime, octet_length(bytes) FROM topo.icons WHERE tenant_id = $1 ORDER BY name `, tenantID) if err != nil { return err } defer rows.Close() for rows.Next() { var i Icon if err := rows.Scan(&i.ID, &i.Name, &i.Mime, &i.Size); err != nil { return err } out = append(out, i) } return rows.Err() }) return out, err } // IconBytes віддає саму картинку. func (s *Store) IconBytes(ctx context.Context, tenantID, id string) ([]byte, string, error) { var b []byte var mime string err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { return tx.QueryRow(ctx, ` SELECT bytes, mime FROM topo.icons WHERE id = $1 AND tenant_id = $2 `, id, tenantID).Scan(&b, &mime) }) if err != nil { if isNoRows(err) { return nil, "", ErrNotFound } return nil, "", err } return b, mime, nil } // CreateIcon зберігає картинку. func (s *Store) CreateIcon(ctx context.Context, tenantID, userID, name, mime string, data []byte) (string, error) { var id string err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { return tx.QueryRow(ctx, ` INSERT INTO topo.icons (tenant_id, name, mime, bytes, created_by) VALUES ($1, $2, $3, $4, $5) RETURNING id::text `, tenantID, name, mime, data, nullUUID(userID)).Scan(&id) }) return id, err } // DeleteIcon прибирає картинку. // // Вузли, що на неї посилались, лишаються — просто повертаються до // значка за типом. Каскадом чистити style у JSON довелося б обходом // усіх мап, а користі з цього рівно стільки ж. func (s *Store) DeleteIcon(ctx context.Context, tenantID, id string) error { return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { ct, err := tx.Exec(ctx, `DELETE FROM topo.icons WHERE id = $1 AND tenant_id = $2`, id, tenantID) if err != nil { return err } if ct.RowsAffected() == 0 { return ErrNotFound } return nil }) }